From 4d65457a653948f70777c17a08f90131dd95b7ec Mon Sep 17 00:00:00 2001 From: Trajkov Dimitar Date: Mon, 15 Dec 2025 12:35:23 +0100 Subject: [PATCH] feat: surrender option for duels --- resources/lang/en.json | 7 +- src/client/RankedQueue.ts | 78 ++++++++----------- src/client/Transport.ts | 13 ++++ .../graphics/layers/GameRightSidebar.ts | 51 +++++++++++- src/core/ApiSchemas.ts | 6 ++ src/core/Schemas.ts | 9 ++- src/core/execution/ExecutionManager.ts | 5 ++ src/core/execution/SurrenderExecution.ts | 60 ++++++++++++++ src/core/execution/WinCheckExecution.ts | 3 +- src/core/game/Game.ts | 1 + src/core/game/GameImpl.ts | 3 +- src/server/MatchmakingPoller.ts | 2 +- src/server/RankedGameConfig.ts | 8 +- src/server/Worker.ts | 23 ++++-- 14 files changed, 207 insertions(+), 62 deletions(-) create mode 100644 src/core/execution/SurrenderExecution.ts diff --git a/resources/lang/en.json b/resources/lang/en.json index 82bc493bb..0626d36b4 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -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", diff --git a/src/client/RankedQueue.ts b/src/client/RankedQueue.ts index b0e1b77e3..b4767ce16 100644 --- a/src/client/RankedQueue.ts +++ b/src/client/RankedQueue.ts @@ -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")} +
+ ${translateText("ranked_queue.ffa")} + ${this.isLoadingElo + ? "..." + : (this.playerEloByMode.ffa ?? 1500)} + ELO +
` @@ -523,24 +527,6 @@ export class RankedQueue extends LitElement { `} - - ${this.isRankedMode - ? html` -
- ${this.isLoadingElo - ? html`${translateText("ranked_queue.loading_elo")}` - : this.currentPlayerElo !== null - ? html`${translateText("ranked_queue.your_elo")} - ${this.currentPlayerElo}` - : ""} -
- ` - : ""} - + + `; + } } + diff --git a/src/core/ApiSchemas.ts b/src/core/ApiSchemas.ts index 2485939ad..03f0a6449 100644 --- a/src/core/ApiSchemas.ts +++ b/src/core/ApiSchemas.ts @@ -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; diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts index 0b1874a09..2e04a799b 100644 --- a/src/core/Schemas.ts +++ b/src/core/Schemas.ts @@ -48,7 +48,8 @@ export type Intent = | UpgradeStructureIntent | DeleteUnitIntent | KickPlayerIntent - | TogglePauseIntent; + | TogglePauseIntent + | SurrenderIntent; export type AttackIntent = z.infer; export type CancelAttackIntent = z.infer; @@ -81,6 +82,7 @@ export type AllianceExtensionIntent = z.infer< export type DeleteUnitIntent = z.infer; export type KickPlayerIntent = z.infer; export type TogglePauseIntent = z.infer; +export type SurrenderIntent = z.infer; export type Turn = z.infer; export type GameConfig = z.infer; @@ -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, ]); // diff --git a/src/core/execution/ExecutionManager.ts b/src/core/execution/ExecutionManager.ts index a161e5eb1..f98cc27d2 100644 --- a/src/core/execution/ExecutionManager.ts +++ b/src/core/execution/ExecutionManager.ts @@ -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`); } diff --git a/src/core/execution/SurrenderExecution.ts b/src/core/execution/SurrenderExecution.ts new file mode 100644 index 000000000..d8e677a65 --- /dev/null +++ b/src/core/execution/SurrenderExecution.ts @@ -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; + } +} diff --git a/src/core/execution/WinCheckExecution.ts b/src/core/execution/WinCheckExecution.ts index be9793cb8..4a111989e 100644 --- a/src/core/execution/WinCheckExecution.ts +++ b/src/core/execution/WinCheckExecution.ts @@ -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(); diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts index 5920dddfd..0d38c15ec 100644 --- a/src/core/game/Game.ts +++ b/src/core/game/Game.ts @@ -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); diff --git a/src/core/game/GameImpl.ts b/src/core/game/GameImpl.ts index 2e9b800f4..3ef3d5a25 100644 --- a/src/core/game/GameImpl.ts +++ b/src/core/game/GameImpl.ts @@ -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; diff --git a/src/server/MatchmakingPoller.ts b/src/server/MatchmakingPoller.ts index e77bd2da3..555705bea 100644 --- a/src/server/MatchmakingPoller.ts +++ b/src/server/MatchmakingPoller.ts @@ -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; }; diff --git a/src/server/RankedGameConfig.ts b/src/server/RankedGameConfig.ts index 48ce769ef..88c277455 100644 --- a/src/server/RankedGameConfig.ts +++ b/src/server/RankedGameConfig.ts @@ -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, diff --git a/src/server/Worker.ts b/src/server/Worker.ts index 2059f0c00..f4ffcd792 100644 --- a/src/server/Worker.ts +++ b/src/server/Worker.ts @@ -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); }