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:
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { CURRENT_RULES, createGame, game, resolveRound } from './index';
|
||||
|
||||
describe('High Card', () => {
|
||||
it('scores the highest number named by exactly one seat', () => {
|
||||
let s = createGame({ A: 'Ann', B: 'Bo', C: 'Cy' }, 1);
|
||||
s = resolveRound(s, { A: { pick: 5 }, B: { pick: 5 }, C: { pick: 2 } });
|
||||
expect(s.rounds[0].scorer).toBe('C');
|
||||
expect(s.players.C.points).toBe(1);
|
||||
});
|
||||
|
||||
it('ends when a seat reaches the target', () => {
|
||||
let s = createGame({ A: 'Ann', B: 'Bo' }, 1);
|
||||
for (let i = 0; i < 3; i++) s = resolveRound(s, { A: { pick: 3 }, B: { pick: 1 } });
|
||||
expect(s.over?.winner).toBe('A');
|
||||
expect(game.needsInput(s, 'A')).toBe(false);
|
||||
});
|
||||
|
||||
it('replays to the same bot picks from the same seed', () => {
|
||||
const a = createGame({ A: 'Ann', B: 'Bo' }, 42);
|
||||
const b = createGame({ A: 'Ann', B: 'Bo' }, 42);
|
||||
expect(game.botInput(a, 'B')).toEqual(game.botInput(b, 'B'));
|
||||
});
|
||||
|
||||
it('stamps the rules revision', () => {
|
||||
expect(createGame({ A: 'Ann', B: 'Bo' }).rules).toBe(CURRENT_RULES);
|
||||
expect(createGame({ A: 'Ann', B: 'Bo' }, 1, 1).rules).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
// The contract between a game and everything else in this kit. The server,
|
||||
// the client store and the hall know nothing about the game beyond this
|
||||
// file: they create a state from names and a seed, feed it one round of
|
||||
// inputs at a time, ask who still has to move, and hand each viewer the
|
||||
// view they are allowed to see. A new game implements GameSpec once, in
|
||||
// src/lib/game/index.ts, and the rest of the kit works unchanged.
|
||||
|
||||
/** A seat at the table: a single letter from GameSpec.seatIds. */
|
||||
export type SeatId = string;
|
||||
|
||||
/** The viewer with no seat: the Peanut Gallery. Views built for it show only what every seat could see. */
|
||||
export const SPECTATOR: SeatId = '';
|
||||
|
||||
export interface Outcome {
|
||||
winner: SeatId | null;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface GameSpec<State, Input> {
|
||||
/** Seats in table order; the table takes at most this many. */
|
||||
seatIds: SeatId[];
|
||||
minSeats: number;
|
||||
/** Names the server draws for bots, in preference order. */
|
||||
botNames: string[];
|
||||
/**
|
||||
* The rules revision new games begin under. A game keeps the revision it
|
||||
* started with, recorded on its ledger, so a later fix can keep the old
|
||||
* path for old ledgers behind `state.rules < N`. Bump only when the deploy
|
||||
* gate shows a fix changes how an already-played turn resolves.
|
||||
*/
|
||||
currentRules: number;
|
||||
|
||||
create(names: Record<SeatId, string>, seed: number, rules: number): State;
|
||||
/** Resolve one round. Must be a pure function of its arguments: the ledger is replayed through it. */
|
||||
resolve(state: State, inputs: Record<SeatId, Input>): State;
|
||||
/** Whether this seat's input is needed before the next resolution. */
|
||||
needsInput(state: State, seat: SeatId): boolean;
|
||||
over(state: State): Outcome | null;
|
||||
/** The round being written, counted from one, for the ledger and the report pin. */
|
||||
turn(state: State): number;
|
||||
/** What one viewer may see. The server sends nothing else. */
|
||||
view(state: State, viewer: SeatId): State;
|
||||
botInput(state: State, seat: SeatId): Input;
|
||||
/** Only the shapes the engine understands get through; the engine validates the rest. */
|
||||
cleanInput(raw: unknown): Input;
|
||||
/** A seat's name from the state, for the hall and the chronicle. */
|
||||
nameOf(state: State, seat: SeatId): string;
|
||||
}
|
||||
Reference in New Issue
Block a user