diff --git a/app/components/prompt/prompt.tsx b/app/components/prompt/prompt.tsx index 196f2bf..478c0a9 100644 --- a/app/components/prompt/prompt.tsx +++ b/app/components/prompt/prompt.tsx @@ -9,6 +9,7 @@ import type { Action, Player } from "~/engine"; import { CANT_BUZZ_FLAG, CLUE_TIMEOUT_MS, + QUANTIZATION_FACTOR_MS, GameState, useEngineContext, } from "~/engine"; @@ -402,7 +403,7 @@ function ReadCluePrompt({ if ( buzzUserId !== userId && buzz !== CANT_BUZZ_FLAG && - buzz < deltaMs + (buzz + QUANTIZATION_FACTOR_MS) < deltaMs ) { submitBuzz(CLUE_TIMEOUT_MS + 1); } diff --git a/app/engine/engine.test.ts b/app/engine/engine.test.ts index b6ba41c..705fc38 100644 --- a/app/engine/engine.test.ts +++ b/app/engine/engine.test.ts @@ -2208,16 +2208,22 @@ describe("gameEngine", () => { }); describe("getWinningBuzzer", () => { - it("returns the buzz of the lower user ID in case of a tie", () => { - // NB: Maps iterate over keys in insertion order - let buzzes = new Map(); - buzzes.set(PLAYER1.userId, 100); - buzzes.set(PLAYER2.userId, 100); - expect(getWinningBuzzer(buzzes)?.userId).toBe(PLAYER1.userId); - - buzzes = new Map(); - buzzes.set(PLAYER2.userId, 100); - buzzes.set(PLAYER1.userId, 100); - expect(getWinningBuzzer(buzzes)?.userId).toBe(PLAYER1.userId); + it("measures the buzzer winrate between two equally fast players over 1000 trials. Expected to be near 50-50", () => { + let player1Wins = 0; + const n_trials = 1000; + for (let trial = 0; trial < n_trials; trial++) { + const tiebreakSeed = Math.random().toString() + trial.toString(); + const buzzes = new Map(); + buzzes.set(PLAYER1.userId, 100); + buzzes.set(PLAYER2.userId, 100); + const winner = getWinningBuzzer(buzzes, tiebreakSeed)?.userId; + if (winner === PLAYER1.userId) { + player1Wins++; + } + } + // CDF of bin(n=1000, p=0.5) = 0.99999 @ k = 435 + const k = 435; + expect(player1Wins).toBeGreaterThan(k); + expect(player1Wins).toBeLessThan(n_trials - k); }); }); diff --git a/app/engine/engine.ts b/app/engine/engine.ts index 5d6bbe8..b5a2bb4 100644 --- a/app/engine/engine.ts +++ b/app/engine/engine.ts @@ -2,6 +2,7 @@ import { enableMapSet, produce } from "immer"; import type { Board } from "~/models/convert.server"; +import { cyrb53 } from "~/utils"; import { isAnswerAction, isBuzzAction, @@ -43,34 +44,70 @@ export const CLUE_TIMEOUT_MS = 5000; * on this clue. */ export const CANT_BUZZ_FLAG = -1; -export function getWinningBuzzer(buzzes: Map): +/** Buzzes within this many milliseconds of each other are treated as ties. */ +export const QUANTIZATION_FACTOR_MS = 200; + +function isValidBuzz(deltaMs: number): boolean { + return deltaMs !== CANT_BUZZ_FLAG && deltaMs <= CLUE_TIMEOUT_MS; +} + +/** getWinningBuzzer returns undefined if there were no valid buzzes. */ +export function getWinningBuzzer( + buzzes: Map, + tiebreakerSeed?: string, +): | { userId: string; deltaMs: number; } | undefined { - const result = Array.from(buzzes.entries()) - // Sort buzzes by user ID for deterministic results in case of a tie. - .sort(([aUserId], [bUserId]) => (aUserId > bUserId ? 1 : -1)) - .reduce( - (acc, [userId, deltaMs]) => { - if ( - deltaMs !== CANT_BUZZ_FLAG && - deltaMs < acc.deltaMs && - deltaMs <= CLUE_TIMEOUT_MS - ) { - return { userId, deltaMs }; - } - return acc; - }, - { userId: "", deltaMs: Number.MAX_SAFE_INTEGER }, - ); + const validBuzzes = Array.from(buzzes.entries()).filter(([, deltaMs]) => + isValidBuzz(deltaMs), + ); - if (result.userId === "") { + if (validBuzzes.length === 0) { return undefined; } - return result; + if (tiebreakerSeed === undefined) { + tiebreakerSeed = "t"; + console.warn( + "TiebreakerSeed is undefined, ties will be broken in a fixed user order.", + ); + } + // generate 53-bit hash, discard MSBs to get 32-bit unsigned + const tiebreakSeed32 = cyrb53(tiebreakerSeed) >>> 0; + + const minDeltaMs = Math.min(...validBuzzes.map(([, deltaMs]) => deltaMs)); + + const quantizedBuzzes: [string, number, number][] = validBuzzes.map( + ([userId, deltaMs]) => [ + userId, + // measure every buzz relative to the fastest one, and round to the quantization interval + Math.floor(Math.max(0, deltaMs - minDeltaMs) / QUANTIZATION_FACTOR_MS), + // random number derived from user ID and per-contest seed, to break ties + cyrb53(userId, tiebreakSeed32), + ], + ); + + quantizedBuzzes.forEach(([userId, qDeltaMs, tiebreak], index) => { + console.log( + `User: ${userId}, Raw: ${validBuzzes[index][1]}, Quantized: ${qDeltaMs}, Tiebreaker: ${tiebreak}`, + ); + }); + + const sortedBuzzes = quantizedBuzzes.sort( + ([, qDeltaA, tiebreakA], [, qDeltaB, tiebreakB]) => { + if (qDeltaA === qDeltaB) { + return tiebreakA - tiebreakB; + } else { + return qDeltaA - qDeltaB; + } + }, + ); + + const [userId, deltaMs] = sortedBuzzes[0]; + return { userId, deltaMs }; } /** getHighestClueValue gets the highest clue value on the board. */ @@ -300,13 +337,14 @@ export function gameEngine(state: State, action: Action): State { return; } - const winningBuzzer = getWinningBuzzer(draft.buzzes); + const board = draft.game.boards.at(draft.round); + const clue = board?.categories.at(j)?.clues.at(i); + + const winningBuzzer = getWinningBuzzer(draft.buzzes, clue?.clue); if (!winningBuzzer) { // Reveal the answer to everyone and mark it as answered. If the clue // was wagerable and the player didn't buzz, deduct their wager from // their score. - const board = draft.game.boards.at(draft.round); - const clue = board?.categories.at(j)?.clues.at(i); if (clue?.wagerable) { const clueValue = getClueValue(draft, [i, j], userId); const player = draft.players.get(userId); @@ -382,6 +420,10 @@ export function gameEngine(state: State, action: Action): State { } const isLongForm = draft.type === GameState.RevealAnswerLongForm; + const board = draft.game.boards.at(draft.round); + const clue = board?.categories.at(j)?.clues.at(i); + const clueText = clue?.clue; + // Ignore the action if it was from a player who didn't answer the clue. const key = `${draft.round},${i},${j}`; if (isLongForm) { @@ -390,7 +432,7 @@ export function gameEngine(state: State, action: Action): State { return; } } else { - const winningBuzzer = getWinningBuzzer(draft.buzzes); + const winningBuzzer = getWinningBuzzer(draft.buzzes, clueText); if (userId !== winningBuzzer?.userId) { return; } diff --git a/app/engine/index.ts b/app/engine/index.ts index 63471aa..b9a8347 100644 --- a/app/engine/index.ts +++ b/app/engine/index.ts @@ -2,11 +2,12 @@ export { ActionType, CANT_BUZZ_FLAG, CLUE_TIMEOUT_MS, + QUANTIZATION_FACTOR_MS, gameEngine, getHighestClueValue, } from "./engine"; export type { Action } from "./engine"; -export { clueIsPlayable, GameState } from "./state"; +export { GameState, clueIsPlayable } from "./state"; export type { Player, State } from "./state"; export { GameEngineContext, useEngineContext } from "./use-engine-context"; export { useGameEngine, useSoloGameEngine } from "./use-game-engine"; diff --git a/app/engine/use-game-engine.ts b/app/engine/use-game-engine.ts index 5f5dc09..d7a855a 100644 --- a/app/engine/use-game-engine.ts +++ b/app/engine/use-game-engine.ts @@ -8,7 +8,7 @@ import { getSupabase } from "~/supabase"; import type { Action } from "./engine"; import { gameEngine, getWinningBuzzer } from "./engine"; import { applyRoomEventsToState, isTypedRoomEvent } from "./room-event"; -import { getClueValue, State, stateFromGame } from "./state"; +import { State, getClueValue, stateFromGame } from "./state"; export enum ConnectionState { ERROR, @@ -55,7 +55,7 @@ function stateToGameEngine( ?.answeredBy.get(userId); }; - const winningBuzz = getWinningBuzzer(state.buzzes); + const winningBuzz = getWinningBuzzer(state.buzzes, clue?.clue); const winningBuzzer = winningBuzz?.userId ?? undefined; function getClueValueFn(idx: [number, number], userId: string) { diff --git a/app/utils/index.ts b/app/utils/index.ts index 9eda91a..c57f3d2 100644 --- a/app/utils/index.ts +++ b/app/utils/index.ts @@ -3,6 +3,7 @@ export * from "./http.server"; export * from "./is-browser"; export { getRandomEmoji, getRandomName } from "./name"; export { + cyrb53, formatDollars, formatDollarsWithSign, generateGrid, diff --git a/app/utils/utils.ts b/app/utils/utils.ts index cdbff7e..0cb1171 100644 --- a/app/utils/utils.ts +++ b/app/utils/utils.ts @@ -42,7 +42,7 @@ export function formatDollarsWithSign(dollars: number) { return signFormatter.format(dollars); } -const cyrb53 = (str: string, seed = 0) => { +export const cyrb53 = (str: string, seed = 0) => { let h1 = 0xdeadbeef ^ seed, h2 = 0x41c6ce57 ^ seed; for (let i = 0, ch; i < str.length; i++) {