The game kit: the shared infrastructure of Wiz-War and Waving Hands as a template

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0141G6xqLeNRYEtviLWSB5Up
This commit is contained in:
Eric WagonerandClaude Fable 5.1 committed 2026-09-23 11:00:05 -04:00
commit 1cd24e3ddd
59 files changed
+7420

No files matched your search

+95
View File
@@ -0,0 +1,95 @@
// 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 } 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<SeatId, number>;
scorer: SeatId | null;
}
export interface State {
rules: number;
seats: SeatId[];
players: Record<SeatId, Player>;
rounds: Round[];
over: Outcome | null;
rng: number;
}
export type Input = { pick: number };
/** Mulberry32: a small seeded generator, so a game replays to the same bot picks. */
function next(state: State): number {
let t = (state.rng += 0x6d2b79f5) >>> 0;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
state.rng = state.rng >>> 0;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
}
export function createGame(names: Record<SeatId, string>, seed = Date.now(), rules = CURRENT_RULES): 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<SeatId, Input>): State {
const state = structuredClone(previous);
if (state.over) return state;
const picks: Record<SeatId, number> = {};
for (const id of state.seats) picks[id] = inputs[id]?.pick ?? 1;
const counts = new Map<number, SeatId[]>();
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} reached ${TARGET} points.` };
}
return state;
}
export const game: GameSpec<State, Input> = {
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)),
botInput: (state) => ({ pick: 1 + Math.floor(next(structuredClone(state)) * HIGHEST) }),
cleanInput: (raw) => {
const pick = Number((raw as { pick?: unknown })?.pick);
return { pick: Number.isInteger(pick) && pick >= 1 && pick <= HIGHEST ? pick : 1 };
},
nameOf: (state, seat) => state.players[seat]?.name ?? seat
};