Begin Hnefatafl from the game kit

This commit is contained in:
Eric Wagoner
2026-09-23 12:45:44 -04:00
commit ed1dcad259
59 changed files with 8048 additions and 0 deletions
+69
View File
@@ -0,0 +1,69 @@
// 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;
choices: { value: string; label: 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>;
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[];
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;
}