Conventions gain a section on in-game language: an image with every fact, the fact exact, unfolding effects told a turn at a time and naming the turn they end on, our own words rather than an older version's, and chronicle wording shipped without a rules revision. new-game.sh points at it, and the demo game's lines follow it. Born in Waving Hands' atmospheric chronicle. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0141G6xqLeNRYEtviLWSB5Up
95 lines
3.5 KiB
TypeScript
95 lines
3.5 KiB
TypeScript
// 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<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 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<SeatId, string>, 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<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} lays down the last card and sweeps the table: ${TARGET} points, and the game.` };
|
|
}
|
|
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)),
|
|
// 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
|
|
};
|