// The demo game, "High Card": every round each seat names a number from one // to five; the highest number named by exactly one seat scores it a point, // and the first to three points wins. It exists to show the shape of a game // this kit can run: simultaneous hidden moves, a seeded bot, a view that // withholds the round in progress, and a rules revision. Replace this file // with your own game and keep the GameSpec shape. import { SPECTATOR, type GameSpec, type Outcome, type SeatId, type TableOptions } from './spec'; export const CURRENT_RULES = 1; export const TARGET = 3; export const HIGHEST = 5; export interface Player { id: SeatId; name: string; points: number; } export interface Round { picks: Record; scorer: SeatId | null; } export interface State { rules: number; seats: SeatId[]; players: Record; rounds: Round[]; over: Outcome | null; rng: number; } export type Input = { pick: number }; /** Mulberry32 on a seed: a small generator, so a game replays to the same bot picks. */ function random(seed: number): number { let t = (seed + 0x6d2b79f5) >>> 0; t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61); return ((t ^ (t >>> 14)) >>> 0) / 4294967296; } export function createGame(names: Record, seed = Date.now(), rules = CURRENT_RULES, _options?: TableOptions): State { const seats = Object.keys(names); return { rules, seats, players: Object.fromEntries(seats.map((id) => [id, { id, name: names[id], points: 0 }])), rounds: [], over: null, rng: seed >>> 0 || 1 }; } export function resolveRound(previous: State, inputs: Record): State { const state = structuredClone(previous); if (state.over) return state; const picks: Record = {}; for (const id of state.seats) picks[id] = inputs[id]?.pick ?? 1; const counts = new Map(); for (const id of state.seats) counts.set(picks[id], [...(counts.get(picks[id]) ?? []), id]); let scorer: SeatId | null = null; for (let n = HIGHEST; n >= 1 && !scorer; n--) { const who = counts.get(n); if (who?.length === 1) scorer = who[0]; } if (scorer) state.players[scorer].points += 1; state.rounds.push({ picks, scorer }); if (scorer && state.players[scorer].points >= TARGET) { state.over = { winner: scorer, reason: `${state.players[scorer].name} lays down the last card and sweeps the table: ${TARGET} points, and the game.` }; } return state; } export const game: GameSpec = { seatIds: 'ABCDEFGH'.split(''), minSeats: 2, botNames: ['Aldric', 'Morwenna', 'Thessaly', 'Gandric', 'Ysolde', 'Ormund', 'Corwin', 'Isaura'], currentRules: CURRENT_RULES, create: createGame, resolve: resolveRound, needsInput: (state) => !state.over, over: (state) => state.over, turn: (state) => state.rounds.length + 1, // Every finished round is public; nothing is hidden, so the gallery sees what a seat sees. view: (state, viewer) => (viewer === SPECTATOR ? structuredClone(state) : structuredClone(state)), // A function of the state alone: the seed, the round and the seat, so a replay draws the same pick. botInput: (state, seat) => ({ pick: 1 + Math.floor(random(state.rng + state.rounds.length * 7919 + state.seats.indexOf(seat)) * HIGHEST) }), cleanInput: (raw) => ({ pick: Number((raw as { pick?: unknown })?.pick) }), validate: (_state, _seat, input) => Number.isInteger(input.pick) && input.pick >= 1 && input.pick <= HIGHEST ? null : `Name a number from 1 to ${HIGHEST}.`, nameOf: (state, seat) => state.players[seat]?.name ?? seat };