feat: surrender option for duels

This commit is contained in:
Trajkov Dimitar
2025-12-30 00:07:34 +01:00
parent 7a6f84ac95
commit 4d65457a65
14 changed files with 207 additions and 62 deletions
+6 -1
View File
@@ -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
View File
@@ -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
+13
View File
@@ -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 = {
+48 -3
View File
@@ -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>
`;
}
}
+6
View File
@@ -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
View File
@@ -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,
]);
//
+5
View File
@@ -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`);
}
+60
View File
@@ -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;
}
}
+2 -1
View File
@@ -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();
+1
View File
@@ -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);
+2 -1
View File
@@ -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;
+1 -1
View File
@@ -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;
};
+6 -2
View File
@@ -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
View File
@@ -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);
}