mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 19:36:43 +00:00
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:
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user