diff --git a/eslint.config.js b/eslint.config.js index a135c9320..542fd98d4 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -111,6 +111,7 @@ export default [ "no-unused-vars": "off", // @typescript-eslint/no-unused-vars "quote-props": ["error", "consistent-as-needed"], // 'sort-imports': 'error', // TODO: Enable this rule, https://github.com/openfrontio/OpenFrontIO/issues/1787 + "space-before-blocks": ["error", "always"], "space-before-function-paren": ["error", { anonymous: "always", named: "never", diff --git a/src/server/Client.ts b/src/server/Client.ts index 68f0a2bfb..ecac7f885 100644 --- a/src/server/Client.ts +++ b/src/server/Client.ts @@ -1,13 +1,15 @@ import WebSocket from "ws"; import { TokenPayload } from "../core/ApiSchemas"; import { Tick } from "../core/game/Game"; -import { ClientID } from "../core/Schemas"; +import { ClientID, Winner } from "../core/Schemas"; export class Client { public lastPing: number = Date.now(); public hashes: Map = new Map(); + public reportedWinner: Winner | null = null; + constructor( public readonly clientID: ClientID, public readonly persistentID: string, diff --git a/src/server/GameServer.ts b/src/server/GameServer.ts index 98003376d..9566ff3a7 100644 --- a/src/server/GameServer.ts +++ b/src/server/GameServer.ts @@ -65,6 +65,11 @@ export class GameServer { private websockets: Set = new Set(); + winnerVotes: Map< + string, + { winner: ClientSendWinnerMessage; ips: Set } + > = new Map(); + constructor( public readonly id: string, readonly log_: Logger, @@ -191,6 +196,7 @@ export class GameServer { } client.lastPing = existing.lastPing; + client.reportedWinner = existing.reportedWinner; this.activeClients = this.activeClients.filter((c) => c !== existing); } diff --git a/src/server/worker/websocket/handler/message/PostJoinHandler.ts b/src/server/worker/websocket/handler/message/PostJoinHandler.ts index 83d2b242a..f2deb50b4 100644 --- a/src/server/worker/websocket/handler/message/PostJoinHandler.ts +++ b/src/server/worker/websocket/handler/message/PostJoinHandler.ts @@ -2,6 +2,7 @@ import { Logger } from "winston"; import { z } from "zod"; import { ClientMessageSchema, + ClientSendWinnerMessage, ServerErrorMessage, } from "../../../../../core/Schemas"; import { Client } from "../../../../Client"; @@ -39,51 +40,13 @@ export async function postJoinMessageHandler( ); return; } - switch (clientMsg.intent.type) { - case "mark_disconnected": { - log.warn(`Should not receive mark_disconnected intent from client`); - return; - } - - // Handle kick_player intent via WebSocket - case "kick_player": { - const authenticatedClientID = client.clientID; - - // Check if the authenticated client is the lobby creator - if (authenticatedClientID !== gs.lobbyCreatorID) { - log.warn(`Only lobby creator can kick players`, { - clientID: authenticatedClientID, - creatorID: gs.lobbyCreatorID, - gameID: gs.id, - target: clientMsg.intent.target, - }); - return; - } - - // Don't allow lobby creator to kick themselves - if (authenticatedClientID === clientMsg.intent.target) { - log.warn(`Cannot kick yourself`, { - clientID: authenticatedClientID, - }); - return; - } - - // Log and execute the kick - log.info(`Lobby creator initiated kick of player`, { - creatorID: authenticatedClientID, - gameID: gs.id, - kickMethod: "websocket", - target: clientMsg.intent.target, - }); - - gs.kickClient(clientMsg.intent.target); - return; - } - default: { - gs.addIntent(clientMsg.intent); - break; - } + if (clientMsg.intent.type === "mark_disconnected") { + log.warn( + `Should not receive mark_disconnected intent from client`, + ); + return; } + gs.addIntent(clientMsg.intent); break; } case "ping": { @@ -96,15 +59,7 @@ export async function postJoinMessageHandler( break; } case "winner": { - if ( - gs.outOfSyncClients.has(client.clientID) || - gs.kickedClients.has(client.clientID) || - gs.winner !== null - ) { - return; - } - gs.winner = clientMsg; - gs.archiveGame(); + handleWinner(gs, log, client, clientMsg); break; } default: { @@ -120,3 +75,49 @@ export async function postJoinMessageHandler( }); } } + +function handleWinner( + gs: GameServer, + log: Logger, + client: Client, clientMsg: ClientSendWinnerMessage) { + if ( + gs.outOfSyncClients.has(client.clientID) || + gs.kickedClients.has(client.clientID) || + gs.winner !== null || + client.reportedWinner !== null + ) { + return; + } + client.reportedWinner = clientMsg.winner; + + // Add client vote + const winnerKey = JSON.stringify(clientMsg.winner); + if (!gs.winnerVotes.has(winnerKey)) { + gs.winnerVotes.set(winnerKey, { ips: new Set(), winner: clientMsg }); + } + const potentialWinner = gs.winnerVotes.get(winnerKey)!; + potentialWinner.ips.add(client.ip); + + const activeUniqueIPs = new Set(gs.activeClients.map((c) => c.ip)); + + // Require at least two unique IPs to agree + if (activeUniqueIPs.size < 2) { + return; + } + + // Check if winner has majority + if (potentialWinner.ips.size * 2 < activeUniqueIPs.size) { + return; + } + + // Vote succeeded + gs.winner = potentialWinner.winner; + log.info( + `Winner determined by ${potentialWinner.ips.size}/${activeUniqueIPs.size} active IPs`, + { + gameID: gs.id, + winnerKey: winnerKey, + }, + ); + gs.archiveGame(); +}