vote for winner (#1565)

## Description:

Have at least 2 clients and majority vote to decide a winner

## Please complete the following:

- [x] I have added screenshots for all UI updates
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [x] I have added relevant tests to the test directory
- [x] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced

---------

Co-authored-by: Scott Anderson <662325+scottanderson@users.noreply.github.com>
This commit is contained in:
evanpelle
2025-08-12 21:59:10 -07:00
committed by GitHub
parent b67b62c826
commit 02e35c3fca
4 changed files with 64 additions and 54 deletions
+1
View File
@@ -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",
+3 -1
View File
@@ -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<Tick, number> = new Map();
public reportedWinner: Winner | null = null;
constructor(
public readonly clientID: ClientID,
public readonly persistentID: string,
+6
View File
@@ -65,6 +65,11 @@ export class GameServer {
private websockets: Set<WebSocket> = new Set();
winnerVotes: Map<
string,
{ winner: ClientSendWinnerMessage; ips: Set<string> }
> = 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);
}
@@ -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();
}