From the hnefatafl repo's pass of the same day, everything that touched a kit-shared file. The visitors digest and the nightly rollup share deploy/traffic.py, installed to /usr/local/lib/<slug>; the rollup writes the finished-games count it computed behind "and False". pull-reports.sh and the reports skill both use deploy/report-digest.ts. The seat line requires its token hash and the start line its rules revision; the migration for ledgers written before hashing goes with them. The route table in server/src/index.ts lists every route; Report, ReportLine and Tally are declared once in view.ts for both sides; exports nobody imported are exports no more. In the client: .small, the × that dismisses, and the frame of the reading pages are in app.css once; the preferences panel shares the report slip's modal shape; the room store gains seatEmpty and seatUnheld, and the lobby and the join page read those instead of three spellings of their own. The room's moved and awaiting fields stay: the demo board reads them, and simultaneous rounds are the contract. The deploy README no longer describes a browser-only game; the visitors skill no longer names a /play route; the reports skill's replay call carries the room's options. The Slack channel id is passed in the environment rather than filled in as a placeholder. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GwFKMuQnPAEHJ5yA1q4orh
86 lines
3.9 KiB
TypeScript
86 lines
3.9 KiB
TypeScript
// 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.
|
|
//
|
|
// The shape is simultaneous rounds: every seat that needs input submits, then
|
|
// the round resolves. Turn-at-a-time games fit too (only one seat needs input
|
|
// at once). A game of single commands with out-of-turn interruptions, like
|
|
// Wiz-War, does not fit this contract and needs its own server.
|
|
|
|
/** 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;
|
|
}
|
|
|
|
/** A choice the host makes when opening a table: a variant, a side, a length. */
|
|
export interface TableOption {
|
|
key: string;
|
|
label: string;
|
|
/** A choice's note is shown under the selector while it is chosen: the one line a player needs to pick. */
|
|
choices: { value: string; label: string; note?: string }[];
|
|
/** The value a table gets when the host chooses nothing. */
|
|
default: string;
|
|
}
|
|
|
|
/** The host's choices for a table, by option key; only keys the game declares get through. */
|
|
export type TableOptions = Record<string, string>;
|
|
|
|
/** A setting the game adds to the preferences panel, kept in the player's browser (see $lib/prefs.svelte). */
|
|
export interface Preference {
|
|
key: string;
|
|
label: string;
|
|
/** A toggle holds a boolean; a choice holds one of its choices' values. */
|
|
kind: 'toggle' | 'choice';
|
|
choices?: { value: string; label: string }[];
|
|
default: boolean | string;
|
|
note?: string;
|
|
}
|
|
|
|
export interface GameSpec<State, Input> {
|
|
/** Seats in table order; the table takes at most this many. */
|
|
seatIds: SeatId[];
|
|
/** What the host may choose when opening a table; empty for a game with one way to play. */
|
|
options?: TableOption[];
|
|
/** The game's own settings for the preferences panel. */
|
|
preferences?: Preference[];
|
|
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, options?: TableOptions): 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;
|
|
/** Why this seat may not make this move now, or null when it may. The server answers with the reason. */
|
|
validate?(state: State, seat: SeatId, input: Input): string | null;
|
|
/** A seat's name from the state, for the hall and the chronicle. */
|
|
nameOf(state: State, seat: SeatId): string;
|
|
/** The game's own lines for the hall's tally, counted from a finished game: a label and how many it adds. */
|
|
tally?(state: State): Record<string, number>;
|
|
}
|