Files
OpenFrontIO/src/server/worker/websocket/handler/message/PostJoinHandler.ts
T
evanpelle 02e35c3fca 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>
2025-08-13 00:59:10 -04:00

124 lines
3.2 KiB
TypeScript

import { Logger } from "winston";
import { z } from "zod";
import {
ClientMessageSchema,
ClientSendWinnerMessage,
ServerErrorMessage,
} from "../../../../../core/Schemas";
import { Client } from "../../../../Client";
import { GameServer } from "../../../../GameServer";
export async function postJoinMessageHandler(
gs: GameServer,
log: Logger,
client: Client,
message: string,
) {
try {
const parsed = ClientMessageSchema.safeParse(JSON.parse(message));
if (!parsed.success) {
const error = z.prettifyError(parsed.error);
log.error("Failed to parse client message", error, {
clientID: client.clientID,
});
client.ws.send(
JSON.stringify({
error,
message,
type: "error",
} satisfies ServerErrorMessage),
);
client.ws.close(1002, "ClientMessageSchema");
return;
}
const clientMsg = parsed.data;
switch (clientMsg.type) {
case "intent": {
if (clientMsg.intent.clientID !== client.clientID) {
log.warn(
`client id mismatch, client: ${client.clientID}, intent: ${clientMsg.intent.clientID}`,
);
return;
}
if (clientMsg.intent.type === "mark_disconnected") {
log.warn(
`Should not receive mark_disconnected intent from client`,
);
return;
}
gs.addIntent(clientMsg.intent);
break;
}
case "ping": {
gs.lastPingUpdate = Date.now();
client.lastPing = Date.now();
break;
}
case "hash": {
client.hashes.set(clientMsg.turnNumber, clientMsg.hash);
break;
}
case "winner": {
handleWinner(gs, log, client, clientMsg);
break;
}
default: {
log.warn(`Unknown message type: ${(clientMsg as any).type}`, {
clientID: client.clientID,
});
break;
}
}
} catch (error) {
log.info(`error handline websocket request in game server: ${error}`, {
clientID: client.clientID,
});
}
}
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();
}