From 2458dd7262458246960d04736790ae07ecb365f7 Mon Sep 17 00:00:00 2001 From: Josh Harris Date: Sun, 12 Jul 2026 12:40:22 +0100 Subject: [PATCH] fix: require strict majority in VoteRound consensus, closing 1v1 winner spoof VoteRound.result() treated an exact tie (votes * 2 >= total) as a majority. With a 1v1 game's electorate of 2 unique IPs, a single client's vote alone satisfied that check (1 * 2 >= 2), letting one player unilaterally declare themselves the winner without the other player agreeing. Requiring a strict majority (votes * 2 > total) closes this while still resolving immediately once the electorate shrinks to 1 (e.g. the other player actually disconnects). Resolves #4136 --- src/server/VoteTally.ts | 7 +++++-- tests/server/VoteTally.test.ts | 27 ++++++++++++++++++++++----- 2 files changed, 27 insertions(+), 7 deletions(-) diff --git a/src/server/VoteTally.ts b/src/server/VoteTally.ts index 8ba1094b21..43427f4861 100644 --- a/src/server/VoteTally.ts +++ b/src/server/VoteTally.ts @@ -22,10 +22,13 @@ export class VoteRound { } // Returns the winning value once some candidate holds a strict majority of - // `totalUniqueIPs` (votes * 2 >= total), else null. + // `totalUniqueIPs` (votes * 2 > total), else null. A tie (e.g. 1 of 2 IPs) + // does not count as a majority: with exactly 2 electors, both must agree, + // otherwise one of two players in a 1v1 could unilaterally declare + // themselves the winner. (#4136) result(totalUniqueIPs: number): { value: T; votes: number } | null { for (const candidate of this.candidates.values()) { - if (candidate.ips.size * 2 >= totalUniqueIPs) { + if (candidate.ips.size * 2 > totalUniqueIPs) { return { value: candidate.value, votes: candidate.ips.size }; } } diff --git a/tests/server/VoteTally.test.ts b/tests/server/VoteTally.test.ts index 41868762a6..acc6fa271c 100644 --- a/tests/server/VoteTally.test.ts +++ b/tests/server/VoteTally.test.ts @@ -8,7 +8,7 @@ describe("VoteRound", () => { // 1 of 3 unique IPs -> no majority yet. expect(round.result(3)).toBeNull(); round.add("a", "a", "2.2.2.2"); - // 2 of 3 -> majority (2 * 2 >= 3). + // 2 of 3 -> majority (2 * 2 > 3). expect(round.result(3)).toEqual({ value: "a", votes: 2 }); }); @@ -25,13 +25,30 @@ describe("VoteRound", () => { round.add("a", "a", "1.1.1.1"); round.add("b", "b", "2.2.2.2"); round.add("a", "a", "3.3.3.3"); - // 'a' has 2 of 4 IPs (2 * 2 >= 4), 'b' has 1. - expect(round.result(4)).toEqual({ value: "a", votes: 2 }); + // 'a' has 2 of 4 IPs, which is only half, not a strict majority (2 * 2 > 4 is false). + expect(round.result(4)).toBeNull(); + round.add("a", "a", "4.4.4.4"); + // 'a' now has 3 of 4 IPs (3 * 2 > 4) -> majority. + expect(round.result(4)).toEqual({ value: "a", votes: 3 }); }); - it("accepts with exactly half the electorate (ties pass)", () => { + it("rejects a tie (exactly half the electorate)", () => { const round = new VoteRound(); round.add("a", "a", "1.1.1.1"); - expect(round.result(2)).toEqual({ value: "a", votes: 1 }); + // 1 of 2 unique IPs is only half, not a strict majority - must not pass. + // Otherwise, in a 1v1 game, one player could unilaterally declare + // themselves the winner without the other player agreeing (#4136). + expect(round.result(2)).toBeNull(); + round.add("a", "a", "2.2.2.2"); + // 2 of 2 -> unanimous, now a strict majority. + expect(round.result(2)).toEqual({ value: "a", votes: 2 }); + }); + + it("still resolves immediately for a lone remaining elector", () => { + const round = new VoteRound(); + round.add("a", "a", "1.1.1.1"); + // If the electorate has shrunk to 1 (e.g. the other player disconnected), + // the sole remaining vote is a strict majority on its own. + expect(round.result(1)).toEqual({ value: "a", votes: 1 }); }); });