From 2c5bba4f9f6576eda6962197cacc1d84d8e621e4 Mon Sep 17 00:00:00 2001 From: qunfengdong Date: Thu, 30 Apr 2026 19:54:45 -0500 Subject: [PATCH] Validate that submitted cards actually form a Set Event handlers checked that cards were distinct and on the board, but never verified they form a Set. Any three distinct on-board cards could be submitted and counted as a Set, inflating scores and Elo. --- functions/src/game.ts | 4 +++ src/util.js | 4 +++ src/util.test.js | 75 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 83 insertions(+) diff --git a/functions/src/game.ts b/functions/src/game.ts index 74d0ddb..6d6a767 100644 --- a/functions/src/game.ts +++ b/functions/src/game.ts @@ -127,6 +127,8 @@ function deleteCards(deck: Set, cards: string[]) { function replayEventNormal(deck: Set, event: GameEvent) { const cards = [event.c1, event.c2, event.c3]; if (!isValid(deck, cards)) return false; + // Cards have to actually form a set, not just be three distinct cards on the board + if (!checkSet(event.c1, event.c2, event.c3)) return false; deleteCards(deck, cards); return true; } @@ -148,6 +150,7 @@ function replayEventChain( const prev = [prevEvent.c1, prevEvent.c2, prevEvent.c3]; ok &&= prev.includes(c1); } + ok &&= checkSet(c1, c2, c3); if (!ok) return; const cards = history.length === 0 ? [c1, c2, c3] : [c2, c3]; @@ -159,6 +162,7 @@ function replayEventChain( function replayEventUltra(deck: Set, event: GameEvent) { const cards = [event.c1, event.c2, event.c3, event.c4!]; if (!isValid(deck, cards)) return false; + if (!checkSetUltra(event.c1, event.c2, event.c3, event.c4!)) return false; deleteCards(deck, cards); return true; } diff --git a/src/util.js b/src/util.js index 1cba530..a0cd318 100644 --- a/src/util.js +++ b/src/util.js @@ -257,6 +257,8 @@ function processEventNormal(internalGameState, event) { const { current, used } = internalGameState; const cards = [event.c1, event.c2, event.c3]; if (hasDuplicates(used, cards)) return; + // Cards have to actually form a set, not just be three distinct cards on the board + if (!checkSet(event.c1, event.c2, event.c3)) return; processValidEvent(internalGameState, event, cards); const minSize = Math.max(internalGameState.boardSize - 3, 12); @@ -276,6 +278,7 @@ function processEventChain(internalGameState, event) { } else { ok &&= !used[c1]; } + ok &&= checkSet(c1, c2, c3); if (!ok) return; const cards = history.length === 0 ? [c1, c2, c3] : [c2, c3]; @@ -291,6 +294,7 @@ function processEventUltra(internalGameState, event) { const { used, current } = internalGameState; const cards = [event.c1, event.c2, event.c3, event.c4]; if (hasDuplicates(used, cards)) return; + if (!checkSetUltra(event.c1, event.c2, event.c3, event.c4)) return; processValidEvent(internalGameState, event, cards); const minSize = Math.max(internalGameState.boardSize - 4, 12); diff --git a/src/util.test.js b/src/util.test.js index 5e8214d..62a05b9 100644 --- a/src/util.test.js +++ b/src/util.test.js @@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest"; import { checkSet, checkSetUltra, + computeState, conjugateCard, filter, findSet, @@ -87,6 +88,80 @@ it("initializes junior deck", () => { expect(initializeDeck(generateCards(), "ultraset")).has.length(81); }); +describe("computeState() rejects non-sets", () => { + function gameData(deck, events) { + return { + deck, + events: Object.fromEntries( + events.map((e, i) => [String(i), { ...e, time: i + 1 }]), + ), + }; + } + + it("normal mode ignores three cards that aren't a set", () => { + const state = computeState( + gameData(generateCards(), [ + { c1: "0000", c2: "0001", c3: "0010", user: "u" }, + ]), + "normal", + ); + expect(state.history).toHaveLength(0); + expect(state.scores).toEqual({}); + }); + + it("normal mode still accepts a real set", () => { + const state = computeState( + gameData(generateCards(), [ + { c1: "0001", c2: "0002", c3: "0000", user: "u" }, + ]), + "normal", + ); + expect(state.scores["u"]).toBe(1); + }); + + it("setchain mode ignores a non-set opening", () => { + const state = computeState( + gameData(generateCards(), [ + { c1: "0000", c2: "0001", c3: "0010", user: "u" }, + ]), + "setchain", + ); + expect(state.history).toHaveLength(0); + }); + + it("setchain mode ignores a non-set continuation", () => { + const state = computeState( + gameData(generateCards(), [ + { c1: "0001", c2: "0002", c3: "0000", user: "u1" }, + { c1: "0000", c2: "0011", c3: "0021", user: "u2" }, + ]), + "setchain", + ); + expect(state.history).toHaveLength(1); + expect(state.scores["u2"]).toBeUndefined(); + }); + + it("ultraset mode ignores four cards that aren't an ultraset", () => { + const state = computeState( + gameData(generateCards(), [ + { c1: "0000", c2: "1111", c3: "2222", c4: "1212", user: "u" }, + ]), + "ultraset", + ); + expect(state.history).toHaveLength(0); + }); + + it("ultraset mode still accepts a real ultraset", () => { + const state = computeState( + gameData(generateCards(), [ + { c1: "0001", c2: "0002", c3: "1202", c4: "2101", user: "u" }, + ]), + "ultraset", + ); + expect(state.scores["u"]).toBe(1); + }); +}); + describe("bad-words filter", () => { it("does not trigger on 'wang'", () => { expect(filter.isProfane("Rona Wang")).toBe(false);