mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-15 18:56:44 +00:00
feat: surrender option for duels
This commit is contained in:
@@ -312,7 +312,12 @@
|
||||
"trios": "Trios",
|
||||
"quads": "Quads",
|
||||
"ranked_matchmaking": "Ranked Matchmaking",
|
||||
"quick_match": "Lobby"
|
||||
"quick_match": "Lobby",
|
||||
"login_required": "Please log in with Discord to join matchmaking"
|
||||
},
|
||||
"duel": {
|
||||
"surrender": "Surrender",
|
||||
"surrender_confirmation": "Are you sure you want to surrender? Your opponent will win the game."
|
||||
},
|
||||
"username": {
|
||||
"enter_username": "Enter your username",
|
||||
|
||||
+32
-46
@@ -57,19 +57,6 @@ export class RankedQueue extends LitElement {
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current player's ELO for the selected game mode
|
||||
* Returns null for unranked modes (duos, trios, quads, unranked ffa)
|
||||
*/
|
||||
private get currentPlayerElo(): number | null {
|
||||
if (this.queueType === "unranked") {
|
||||
return null; // No ELO for unranked modes
|
||||
}
|
||||
return this.gameMode === "duel"
|
||||
? this.playerEloByMode.duel
|
||||
: this.playerEloByMode.ffa;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the current mode is a ranked mode (has ELO tracking)
|
||||
*/
|
||||
@@ -94,7 +81,7 @@ export class RankedQueue extends LitElement {
|
||||
const userMe = await getUserMe();
|
||||
if (userMe !== false) {
|
||||
// Use eloByMode if available, fall back to elo for backward compatibility
|
||||
const eloByMode = (userMe.player as any).eloByMode;
|
||||
const eloByMode = userMe.player.eloByMode;
|
||||
if (eloByMode) {
|
||||
this.playerEloByMode = {
|
||||
ffa: eloByMode.ffa ?? null,
|
||||
@@ -167,21 +154,22 @@ export class RankedQueue extends LitElement {
|
||||
// Get authentication information
|
||||
const loginResult = await userAuth();
|
||||
if (loginResult === false) {
|
||||
throw new Error("Please log in to join ranked matchmaking");
|
||||
throw new Error(translateText("ranked_queue.login_required"));
|
||||
}
|
||||
|
||||
// Check if user is actually logged in (not just a guest with persistent ID)
|
||||
const userMe = await getUserMe();
|
||||
if (userMe === false || (!userMe.user.discord && !userMe.user.email)) {
|
||||
throw new Error(translateText("ranked_queue.login_required"));
|
||||
}
|
||||
|
||||
const token = loginResult.jwt;
|
||||
|
||||
// Determine WebSocket URL based on environment
|
||||
const matchmakingBase = process?.env?.MATCHMAKING_WS_URL;
|
||||
const wsUrl = matchmakingBase
|
||||
? `${matchmakingBase}/matchmaking/join`
|
||||
: (() => {
|
||||
const apiBase = getApiBase();
|
||||
const protocol = apiBase.startsWith("https://") ? "wss:" : "ws:";
|
||||
const host = apiBase.replace(/^https?:\/\//, "");
|
||||
return `${protocol}//${host}/matchmaking/join`;
|
||||
})();
|
||||
// Determine WebSocket URL based on apiBase
|
||||
const apiBase = getApiBase();
|
||||
const protocol = apiBase.startsWith("https://") ? "wss:" : "ws:";
|
||||
const host = apiBase.replace(/^https?:\/\//, "");
|
||||
const wsUrl = `${protocol}//${host}/matchmaking/join`;
|
||||
|
||||
console.log("Connecting to matchmaking WebSocket:", wsUrl);
|
||||
this.ws = new WebSocket(wsUrl);
|
||||
@@ -295,7 +283,7 @@ export class RankedQueue extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
private handleMatchFound(gameId: string, assignment: any) {
|
||||
private handleMatchFound(gameId: string, _assignment: any) {
|
||||
console.log(`Match found! Joining game ${gameId}`);
|
||||
|
||||
// Set URL hash to trigger automatic join
|
||||
@@ -454,7 +442,15 @@ export class RankedQueue extends LitElement {
|
||||
"c-button--disabled": this.inQueue || this.isConnecting,
|
||||
})}
|
||||
>
|
||||
${translateText("ranked_queue.ffa")}
|
||||
<div class="flex flex-col items-center">
|
||||
<span>${translateText("ranked_queue.ffa")}</span>
|
||||
<span class="text-xs opacity-70"
|
||||
>${this.isLoadingElo
|
||||
? "..."
|
||||
: (this.playerEloByMode.ffa ?? 1500)}
|
||||
ELO</span
|
||||
>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
@click=${() => this.setGameMode("duel")}
|
||||
@@ -466,7 +462,15 @@ export class RankedQueue extends LitElement {
|
||||
"c-button--disabled": this.inQueue || this.isConnecting,
|
||||
})}
|
||||
>
|
||||
${translateText("ranked_queue.duel")}
|
||||
<div class="flex flex-col items-center">
|
||||
<span>${translateText("ranked_queue.duel")}</span>
|
||||
<span class="text-xs opacity-70"
|
||||
>${this.isLoadingElo
|
||||
? "..."
|
||||
: (this.playerEloByMode.duel ?? 1500)}
|
||||
ELO</span
|
||||
>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
`
|
||||
@@ -523,24 +527,6 @@ export class RankedQueue extends LitElement {
|
||||
</div>
|
||||
`}
|
||||
|
||||
<!-- ELO Display for ranked modes -->
|
||||
${this.isRankedMode
|
||||
? html`
|
||||
<div class="text-center text-white">
|
||||
${this.isLoadingElo
|
||||
? html`<span class="opacity-70"
|
||||
>${translateText("ranked_queue.loading_elo")}</span
|
||||
>`
|
||||
: this.currentPlayerElo !== null
|
||||
? html`<span
|
||||
>${translateText("ranked_queue.your_elo")}
|
||||
<strong>${this.currentPlayerElo}</strong></span
|
||||
>`
|
||||
: ""}
|
||||
</div>
|
||||
`
|
||||
: ""}
|
||||
|
||||
<!-- Join Queue Button -->
|
||||
<button
|
||||
@click=${this.inQueue
|
||||
|
||||
@@ -175,6 +175,8 @@ export class SendKickPlayerIntentEvent implements GameEvent {
|
||||
constructor(public readonly target: string) {}
|
||||
}
|
||||
|
||||
export class SendSurrenderIntentEvent implements GameEvent {}
|
||||
|
||||
export class Transport {
|
||||
private socket: WebSocket | null = null;
|
||||
|
||||
@@ -260,6 +262,10 @@ export class Transport {
|
||||
this.eventBus.on(SendKickPlayerIntentEvent, (e) =>
|
||||
this.onSendKickPlayerIntent(e),
|
||||
);
|
||||
|
||||
this.eventBus.on(SendSurrenderIntentEvent, () =>
|
||||
this.onSendSurrenderIntent(),
|
||||
);
|
||||
}
|
||||
|
||||
private startPing() {
|
||||
@@ -659,6 +665,13 @@ export class Transport {
|
||||
});
|
||||
}
|
||||
|
||||
private onSendSurrenderIntent() {
|
||||
this.sendIntent({
|
||||
type: "surrender",
|
||||
clientID: this.lobbyConfig.clientID,
|
||||
});
|
||||
}
|
||||
|
||||
private sendIntent(intent: Intent) {
|
||||
if (this.isLocal || this.socket?.readyState === WebSocket.OPEN) {
|
||||
const msg = {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, state } from "lit/decorators.js";
|
||||
import { EventBus } from "../../../core/EventBus";
|
||||
import { GameType } from "../../../core/game/Game";
|
||||
import { GameMode, GameType } from "../../../core/game/Game";
|
||||
import { GameUpdateType } from "../../../core/game/GameUpdates";
|
||||
import { GameView } from "../../../core/game/GameView";
|
||||
import { crazyGamesSDK } from "../../CrazyGamesSDK";
|
||||
import { PauseGameIntentEvent } from "../../Transport";
|
||||
import { PauseGameIntentEvent, SendSurrenderIntentEvent } from "../../Transport";
|
||||
import { translateText } from "../../Utils";
|
||||
import { Layer } from "./Layer";
|
||||
import { ShowReplayPanelEvent } from "./ReplayPanel";
|
||||
@@ -16,6 +16,8 @@ import pauseIcon from "/images/PauseIconWhite.svg?url";
|
||||
import playIcon from "/images/PlayIconWhite.svg?url";
|
||||
import settingsIcon from "/images/SettingIconWhite.svg?url";
|
||||
|
||||
const MIN_GAME_TICKS_FOR_SURRENDER = 3000;
|
||||
|
||||
@customElement("game-right-sidebar")
|
||||
export class GameRightSidebar extends LitElement implements Layer {
|
||||
public game: GameView;
|
||||
@@ -128,6 +130,32 @@ export class GameRightSidebar extends LitElement implements Layer {
|
||||
);
|
||||
}
|
||||
|
||||
private onSurrenderButtonClick() {
|
||||
const isConfirmed = confirm(translateText("duel.surrender_confirmation"));
|
||||
if (!isConfirmed) return;
|
||||
this.eventBus.emit(new SendSurrenderIntentEvent());
|
||||
}
|
||||
|
||||
private isDuelMode(): boolean {
|
||||
return this.game?.config()?.gameConfig()?.gameMode === GameMode.Duel;
|
||||
}
|
||||
|
||||
private canSurrender(): boolean {
|
||||
if (
|
||||
!this.isDuelMode() ||
|
||||
this._isSinglePlayer ||
|
||||
this.hasWinner ||
|
||||
this.game.myPlayer()?.isAlive() !== true
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check minimum game time (after spawn phase)
|
||||
const ticksSinceSpawnPhase =
|
||||
this.game.ticks() - this.game.config().numSpawnPhaseTurns();
|
||||
return ticksSinceSpawnPhase >= MIN_GAME_TICKS_FOR_SURRENDER;
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.game === undefined) return html``;
|
||||
|
||||
@@ -153,7 +181,7 @@ export class GameRightSidebar extends LitElement implements Layer {
|
||||
<div class="cursor-pointer" @click=${this.onSettingsButtonClick}>
|
||||
<img src=${settingsIcon} alt="settings" width="20" height="20" />
|
||||
</div>
|
||||
|
||||
${this.maybeRenderSurrenderButton()}
|
||||
<div class="cursor-pointer" @click=${this.onExitButtonClick}>
|
||||
<img src=${exitIcon} alt="exit" width="20" height="20" />
|
||||
</div>
|
||||
@@ -193,4 +221,21 @@ export class GameRightSidebar extends LitElement implements Layer {
|
||||
: ""}
|
||||
`;
|
||||
}
|
||||
|
||||
maybeRenderSurrenderButton() {
|
||||
if (!this.canSurrender()) {
|
||||
return html``;
|
||||
}
|
||||
return html`
|
||||
<div class="flex justify-center mt-2">
|
||||
<button
|
||||
class="px-2 py-1 text-xs bg-red-600 hover:bg-red-700 text-white rounded cursor-pointer"
|
||||
@click=${this.onSurrenderButtonClick}
|
||||
>
|
||||
${translateText("duel.surrender")}
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -52,6 +52,12 @@ export const UserMeResponseSchema = z.object({
|
||||
roles: z.string().array().optional(),
|
||||
flares: z.string().array().optional(),
|
||||
elo: z.number().optional(),
|
||||
eloByMode: z
|
||||
.object({
|
||||
ffa: z.number().nullable(),
|
||||
duel: z.number().nullable(),
|
||||
})
|
||||
.optional(),
|
||||
}),
|
||||
});
|
||||
export type UserMeResponse = z.infer<typeof UserMeResponseSchema>;
|
||||
|
||||
+8
-1
@@ -48,7 +48,8 @@ export type Intent =
|
||||
| UpgradeStructureIntent
|
||||
| DeleteUnitIntent
|
||||
| KickPlayerIntent
|
||||
| TogglePauseIntent;
|
||||
| TogglePauseIntent
|
||||
| SurrenderIntent;
|
||||
|
||||
export type AttackIntent = z.infer<typeof AttackIntentSchema>;
|
||||
export type CancelAttackIntent = z.infer<typeof CancelAttackIntentSchema>;
|
||||
@@ -81,6 +82,7 @@ export type AllianceExtensionIntent = z.infer<
|
||||
export type DeleteUnitIntent = z.infer<typeof DeleteUnitIntentSchema>;
|
||||
export type KickPlayerIntent = z.infer<typeof KickPlayerIntentSchema>;
|
||||
export type TogglePauseIntent = z.infer<typeof TogglePauseIntentSchema>;
|
||||
export type SurrenderIntent = z.infer<typeof SurrenderIntentSchema>;
|
||||
|
||||
export type Turn = z.infer<typeof TurnSchema>;
|
||||
export type GameConfig = z.infer<typeof GameConfigSchema>;
|
||||
@@ -363,6 +365,10 @@ export const TogglePauseIntentSchema = BaseIntentSchema.extend({
|
||||
paused: z.boolean().default(false),
|
||||
});
|
||||
|
||||
export const SurrenderIntentSchema = BaseIntentSchema.extend({
|
||||
type: z.literal("surrender"),
|
||||
});
|
||||
|
||||
const IntentSchema = z.discriminatedUnion("type", [
|
||||
AttackIntentSchema,
|
||||
CancelAttackIntentSchema,
|
||||
@@ -387,6 +393,7 @@ const IntentSchema = z.discriminatedUnion("type", [
|
||||
DeleteUnitIntentSchema,
|
||||
KickPlayerIntentSchema,
|
||||
TogglePauseIntentSchema,
|
||||
SurrenderIntentSchema,
|
||||
]);
|
||||
|
||||
//
|
||||
|
||||
@@ -24,6 +24,7 @@ import { PauseExecution } from "./PauseExecution";
|
||||
import { QuickChatExecution } from "./QuickChatExecution";
|
||||
import { RetreatExecution } from "./RetreatExecution";
|
||||
import { SpawnExecution } from "./SpawnExecution";
|
||||
import { SurrenderExecution } from "./SurrenderExecution";
|
||||
import { TargetPlayerExecution } from "./TargetPlayerExecution";
|
||||
import { TransportShipExecution } from "./TransportShipExecution";
|
||||
import { UpgradeStructureExecution } from "./UpgradeStructureExecution";
|
||||
@@ -131,6 +132,10 @@ export class Executor {
|
||||
return new MarkDisconnectedExecution(player, intent.isDisconnected);
|
||||
case "toggle_pause":
|
||||
return new PauseExecution(player, intent.paused);
|
||||
case "surrender":
|
||||
return new SurrenderExecution(player);
|
||||
case "kick_player":
|
||||
return new NoOpExecution();
|
||||
default:
|
||||
throw new Error(`intent type ${intent} not found`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { Execution, Game, GameMode, Player, PlayerType } from "../game/Game";
|
||||
|
||||
// Minimum game time (after spawn phase) before surrender is allowed: 5 minutes
|
||||
// Game runs at 10 ticks/second, so 5 minutes = 5 * 60 * 10 = 3000 ticks
|
||||
const MIN_GAME_TICKS_FOR_SURRENDER = 3000;
|
||||
|
||||
export class SurrenderExecution implements Execution {
|
||||
private mg: Game | null = null;
|
||||
|
||||
constructor(private player: Player) {}
|
||||
|
||||
init(mg: Game, ticks: number): void {
|
||||
this.mg = mg;
|
||||
|
||||
// Surrender only works in Duel mode
|
||||
const mode = mg.config().gameConfig().gameMode;
|
||||
if (mode !== GameMode.Duel) {
|
||||
console.warn("Surrender is only available in Duel mode");
|
||||
return;
|
||||
}
|
||||
|
||||
// Check minimum game time (after spawn phase)
|
||||
const ticksSinceSpawnPhase = mg.ticks() - mg.config().numSpawnPhaseTurns();
|
||||
if (ticksSinceSpawnPhase < MIN_GAME_TICKS_FOR_SURRENDER) {
|
||||
console.warn(
|
||||
`Cannot surrender yet: ${Math.ceil((MIN_GAME_TICKS_FOR_SURRENDER - ticksSinceSpawnPhase) / 10)} seconds remaining`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the opponent (the other human player in duel)
|
||||
const players = mg
|
||||
.players()
|
||||
.filter((p) => p.type() === PlayerType.Human && p !== this.player);
|
||||
if (players.length !== 1) {
|
||||
console.warn("Cannot surrender: expected exactly one opponent");
|
||||
return;
|
||||
}
|
||||
|
||||
const opponent = players[0];
|
||||
|
||||
// Set the opponent as the winner
|
||||
mg.setWinner(opponent, mg.stats().stats());
|
||||
console.log(
|
||||
`${this.player.name()} surrendered. ${opponent.name()} wins the game.`,
|
||||
);
|
||||
}
|
||||
|
||||
tick(ticks: number): void {
|
||||
return;
|
||||
}
|
||||
|
||||
isActive(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
activeDuringSpawnPhase(): boolean {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,8 @@ export class WinCheckExecution implements Execution {
|
||||
}
|
||||
if (this.mg === null) throw new Error("Not initialized");
|
||||
|
||||
if (this.mg.config().gameConfig().gameMode === GameMode.FFA) {
|
||||
const mode = this.mg.config().gameConfig().gameMode;
|
||||
if (mode === GameMode.FFA || mode === GameMode.Duel) {
|
||||
this.checkWinnerFFA();
|
||||
} else {
|
||||
this.checkWinnerTeam();
|
||||
|
||||
@@ -169,6 +169,7 @@ export const isGameType = (value: unknown): value is GameType =>
|
||||
export enum GameMode {
|
||||
FFA = "Free For All",
|
||||
Team = "Team",
|
||||
Duel = "Duel",
|
||||
}
|
||||
export const isGameMode = (value: unknown): value is GameMode =>
|
||||
isEnumValue(GameMode, value);
|
||||
|
||||
@@ -149,7 +149,8 @@ export class GameImpl implements Game {
|
||||
}
|
||||
|
||||
private addPlayers() {
|
||||
if (this.config().gameConfig().gameMode === GameMode.FFA) {
|
||||
const mode = this.config().gameConfig().gameMode;
|
||||
if (mode === GameMode.FFA || mode === GameMode.Duel) {
|
||||
this._humans.forEach((p) => this.addPlayer(p));
|
||||
this._nations.forEach((n) => this.addPlayer(n.playerInfo));
|
||||
return;
|
||||
|
||||
@@ -7,7 +7,7 @@ export interface MatchAssignment {
|
||||
players: string[]; // Player tokens
|
||||
config: {
|
||||
queueType: "ranked" | "unranked";
|
||||
gameMode: "ffa" | "team";
|
||||
gameMode: "ffa" | "team" | "duel" | "duos" | "trios" | "quads";
|
||||
playerCount: number;
|
||||
teamConfig?: TeamCountConfig;
|
||||
};
|
||||
|
||||
@@ -34,7 +34,11 @@ export function buildRankedGameConfig(
|
||||
gameMode === "duos" ||
|
||||
gameMode === "trios" ||
|
||||
gameMode === "quads";
|
||||
const mode = isTeamMode ? GameMode.Team : GameMode.FFA;
|
||||
const mode = isDuel
|
||||
? GameMode.Duel
|
||||
: isTeamMode
|
||||
? GameMode.Team
|
||||
: GameMode.FFA;
|
||||
|
||||
// Determine team configuration based on game mode
|
||||
let teamConfig: TeamCountConfig | undefined = matchConfig.teamConfig;
|
||||
@@ -55,7 +59,7 @@ export function buildRankedGameConfig(
|
||||
|
||||
bots: 400,
|
||||
difficulty: Difficulty.Medium,
|
||||
disableNPCs: isDuel ? true : false,
|
||||
disableNPCs: true,
|
||||
|
||||
// Donation rules
|
||||
donateGold: isTeamMode,
|
||||
|
||||
+17
-6
@@ -25,7 +25,6 @@ import { getUserMe, verifyClientToken } from "./jwt";
|
||||
import { logger } from "./Logger";
|
||||
|
||||
import { GameEnv } from "../core/configuration/Config";
|
||||
import { MapPlaylist } from "./MapPlaylist";
|
||||
import { selectMapForRanked } from "./MapSelection";
|
||||
import { MatchmakingPoller } from "./MatchmakingPoller";
|
||||
import { PrivilegeRefresher } from "./PrivilegeRefresher";
|
||||
@@ -37,7 +36,6 @@ const config = getServerConfigFromServer();
|
||||
|
||||
const workerId = parseInt(process.env.WORKER_ID ?? "0");
|
||||
const log = logger.child({ comp: `w_${workerId}` });
|
||||
const playlist = new MapPlaylist(true);
|
||||
|
||||
// Worker setup
|
||||
export async function startWorker() {
|
||||
@@ -582,12 +580,25 @@ async function pollLobby(gm: GameManager) {
|
||||
log.info(`Lobby poll successful:`, data);
|
||||
|
||||
if (data.assignment) {
|
||||
// TODO: Only allow specified players to join the game.
|
||||
console.log(`Creating game ${gameId}`);
|
||||
const game = gm.createGame(gameId, playlist.gameConfig());
|
||||
const assignment = data.assignment;
|
||||
log.info(`Creating game ${gameId} with assignment`, assignment.config);
|
||||
|
||||
// Select map based on player count and mode
|
||||
const selectedMap = selectMapForRanked({
|
||||
playerCount: assignment.config.playerCount,
|
||||
gameMode:
|
||||
assignment.config.gameMode === "ffa" ? GameMode.FFA : GameMode.Team,
|
||||
queueType: assignment.config.queueType,
|
||||
matchMode: assignment.config.gameMode,
|
||||
});
|
||||
|
||||
// Build full game config using assignment config
|
||||
const gameConfig = buildRankedGameConfig(selectedMap, assignment.config);
|
||||
|
||||
const game = gm.createGame(gameId, gameConfig);
|
||||
setTimeout(() => {
|
||||
// Wait a few seconds to allow clients to connect.
|
||||
console.log(`Starting game ${gameId}`);
|
||||
log.info(`Starting game ${gameId}`);
|
||||
game.start();
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user