Hashed seat tokens, ledgered inputs, refusable moves, disk-checked codes, host-only bots, and a drift script

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0141G6xqLeNRYEtviLWSB5Up
This commit is contained in:
Eric Wagoner
2026-09-23 12:07:53 -04:00
co-authored by Claude Fable 5.1
parent ddb18cb08a
commit c934bbe434
12 changed files with 173 additions and 47 deletions
+12 -5
View File
@@ -25,9 +25,13 @@ README.
ledger line, so it replays with the game, and the hall counts lines said ledger line, so it replays with the game, and the hall counts lines said
while you were away. while you were away.
- **The ledger** is the append-only JSONL file that *is* the game: room, - **The ledger** is the append-only JSONL file that *is* the game: room,
seats, start (seed and rules revision), turns (inputs only), talk, over. seats, start (seed and rules revision), each seat's submitted input, the
A room is its ledger replayed through a deterministic engine; nothing else turn that resolves them, talk, over. A room is its ledger replayed
is saved. Ledgers survive deploys and restarts and are backed up nightly. through a deterministic engine; nothing else is saved. A half-written
round survives a restart because every input is written as it arrives.
Ledgers survive deploys and restarts and are backed up nightly, so they
hold nothing secret: a seat line carries the SHA-256 of its token, never
the token, which goes once to the browser that earned it.
- **Report** in the masthead opens the slip: what happened, what you - **Report** in the masthead opens the slip: what happened, what you
expected, a screenshot if you have one. It is pinned to the room and the expected, a screenshot if you have one. It is pinned to the room and the
round. **The keeper** replies; the reply appears under the report in the round. **The keeper** replies; the reply appears under the report in the
@@ -68,8 +72,11 @@ a sandboxed systemd service, behind Caddy `handle` blocks that route `/api`
and `/ws` before the static files. Rooms in memory are evicted after a week and `/ws` before the static files. Rooms in memory are evicted after a week
idle and come back from disk on the next visit. Rate limits sit on the doors idle and come back from disk on the next visit. Rate limits sit on the doors
a stranger can knock on (rooms, seats, talk, the desk); a table of friends a stranger can knock on (rooms, seats, talk, the desk); a table of friends
never nears them. Seat tokens never leave the server except to the browser never nears them. A seat token is minted once, sent once, and compared by
that earned them; a shared link carries none. hash in constant time ever after; a shared link carries none. Room codes
are checked against the disk as well as memory, so an evicted room's code
is never reissued over its ledger. A game may refuse a move with a reason
(`validate`), and the player sees the reason.
## Operations ## Operations
+21 -5
View File
@@ -44,9 +44,25 @@ Placeholders: `__SLUG__`, `__NAME__`, `__PORT__`, `__DOMAIN__` are filled by
`__SENTRY_SLACK_INTEGRATION__` and `__SLACK_CHANNEL_ID__` wait until those `__SENTRY_SLACK_INTEGRATION__` and `__SLACK_CHANNEL_ID__` wait until those
things exist. things exist.
## Keeping the kit current ## What the kit is, and is not
When a game grows something every game should have (the gallery and the The model is copy and diverge. A new game starts as a copy of the template
reports desk were both born in one game and ported to the other), bring it and then goes its own way; the two games that came before it are not
back here, with placeholders, so the next game inherits it. The kit is the instances of the template and were not rewritten to be. The kit holds the
canonical copy of the shared parts; the games are its instances. reference copy of the shared parts and the conventions they follow, so a
third game starts from the best current version of everything.
Drift is meant to be visible, not prevented:
./drift.sh ../waving-hands # how far each shared file has wandered
`drift-map/<slug>.txt` pairs files when a game's layout differs from the
template's. When a game grows something every game should have (the
gallery and the reports desk were both born in one game and ported to the
other), bring it back here, with placeholders, so the next game inherits
it, and read the drift before assuming the kit has it.
The contract is simultaneous rounds: every seat that needs input submits,
then the round resolves; turn-at-a-time games fit as the case where one
seat needs input at a time. A game of single commands with out-of-turn
interruptions, Wiz-War's shape, does not fit and keeps its own server.
+2
View File
@@ -0,0 +1,2 @@
src/lib/net/client.ts src/lib/game/client.ts
src/lib/net/talk.ts src/lib/game/talk.ts
Executable
+31
View File
@@ -0,0 +1,31 @@
#!/usr/bin/env bash
# Show how far a game's shared files have drifted from the kit's template.
# ./drift.sh <game-dir> [slug] e.g. ./drift.sh ../waving-hands waving-hands
# Files are paired by drift-map/<slug>.txt when a game's layout differs from
# the template's ("template/path game/path" per line); otherwise the same
# path is compared. Placeholders are filled with the slug before diffing, so
# only real divergence shows. Prints changed lines per file, then the total.
set -euo pipefail
GAME="${1:?usage: drift.sh <game-dir> [slug]}"
SLUG="${2:-$(basename "$(cd "$GAME" && pwd)")}"
KIT="$(cd "$(dirname "$0")" && pwd)"
MAP="$KIT/drift-map/$SLUG.txt"
SHARED="server/src/index.ts server/src/rooms.ts server/src/store.ts server/src/reports.ts server/src/ratelimit.ts
src/lib/net/client.ts src/lib/net/talk.ts src/lib/components/TableTalk.svelte src/lib/components/ReportSlip.svelte
deploy/deploy.sh deploy/setup-droplet.sh deploy/setup-server.sh deploy/Caddyfile.tmpl deploy/verify-ledgers.sh
deploy/replay-ledgers.ts deploy/pulse.sh deploy/visitors.sh deploy/sentry-slack-alert.sh deploy/pull-reports.sh deploy/report-reply.sh
deploy/__SLUG__.service deploy/__SLUG__.cron deploy/__SLUG__-backup.sh deploy/__SLUG__-rollup.sh"
total=0
for t in $SHARED; do
g="$t"
if [ -f "$MAP" ]; then
m=$(awk -v t="$t" '$1 == t { print $2 }' "$MAP")
[ -n "$m" ] && g="$m"
fi
g="${g//__SLUG__/$SLUG}"
if [ ! -f "$GAME/$g" ]; then printf '%-44s %s\n' "$t" "missing in game ($g)"; continue; fi
n=$(diff <(sed -e "s/__SLUG__/$SLUG/g" "$KIT/template/$t") "$GAME/$g" | grep -c '^[<>]' || true)
total=$((total + n))
printf '%-44s %4d lines differ\n' "$t" "$n"
done
echo "total: $total lines differ from the template"
+44
View File
@@ -0,0 +1,44 @@
// One-time migration: replace each seat line's raw token with its SHA-256,
// so no ledger on disk or in a backup holds a live seat key. Idempotent; a
// line already hashed is left alone. Run ON the droplet from the server
// directory (tsx is installed there):
// cd /opt/__SLUG__/app/server && npx tsx ../deploy/hash-tokens.ts /var/lib/__SLUG__/rooms
// Browsers keep their raw tokens; the server hashes what they send and
// compares, so nobody loses a seat.
import { createHash } from 'node:crypto';
import { readdirSync, readFileSync, renameSync, statSync, writeFileSync, chownSync } from 'node:fs';
import { join } from 'node:path';
const dir = process.argv[2];
if (!dir) {
console.error('usage: hash-tokens.ts <ledger-dir>');
process.exit(2);
}
let files = 0;
let lines = 0;
for (const file of readdirSync(dir).filter((f) => f.endsWith('.jsonl'))) {
const path = join(dir, file);
const raw = readFileSync(path, 'utf8');
let changed = 0;
const out = raw
.split('\n')
.map((line) => {
if (!line.trim()) return line;
const entry = JSON.parse(line) as Record<string, unknown>;
if (entry.t !== 'seat' || typeof entry.token !== 'string') return line;
const { token, ...rest } = entry;
changed += 1;
return JSON.stringify({ ...rest, tokenHash: token ? createHash('sha256').update(token).digest('hex') : '' });
})
.join('\n');
if (!changed) continue;
const { uid, gid } = statSync(path);
writeFileSync(path + '.tmp', out);
chownSync(path + '.tmp', uid, gid);
renameSync(path + '.tmp', path);
files += 1;
lines += changed;
}
console.log(`${files} ledger${files === 1 ? '' : 's'} rewritten, ${lines} seat line${lines === 1 ? '' : 's'} hashed`);
+3 -4
View File
@@ -13,7 +13,6 @@ import type { SeatId } from '../src/lib/game/spec';
interface Seat { interface Seat {
id: SeatId; id: SeatId;
name: string; name: string;
token: string;
bot: boolean; bot: boolean;
} }
@@ -52,9 +51,9 @@ async function main(): Promise<void> {
} }
const outcome = state ? game.over(state) : null; const outcome = state ? game.over(state) : null;
let verdict = state ? `${turns} turns, ${outcome ? 'over' : 'in play'}` : 'not started'; let verdict = state ? `${turns} turns, ${outcome ? 'over' : 'in play'}` : 'not started';
const human = seats.find((s) => !s.bot); if (host && state) {
if (host && state && human) { // The gallery's view carries the round and the outcome, which is all the comparison needs.
const res = await fetch(`${host}/api/rooms/${code}?token=${encodeURIComponent(human.token)}`); const res = await fetch(`${host}/api/rooms/${code}`);
if (!res.ok) { if (!res.ok) {
verdict += `, server ${res.status}`; verdict += `, server ${res.status}`;
} else { } else {
+5 -5
View File
@@ -3,7 +3,7 @@
// //
// POST /api/rooms {name, size?} create a room and take seat A // POST /api/rooms {name, size?} create a room and take seat A
// POST /api/rooms/:id/join {name} take the next seat // POST /api/rooms/:id/join {name} take the next seat
// POST /api/rooms/:id/bot {token} seat a bot (any seated player may, before the game begins) // POST /api/rooms/:id/bot {token} the host seats a bot, before the game begins
// POST /api/rooms/:id/begin {token} the host begins with the players seated so far // POST /api/rooms/:id/begin {token} the host begins with the players seated so far
// POST /api/rooms/:id/unseat {token, seat} the host sends a bot away before the game begins // POST /api/rooms/:id/unseat {token, seat} the host sends a bot away before the game begins
// GET /api/rooms/:id?token= the view for that seat; without a token, the gallery's view // GET /api/rooms/:id?token= the view for that seat; without a token, the gallery's view
@@ -171,8 +171,8 @@ async function handle(req: IncomingMessage, res: ServerResponse): Promise<void>
if (parts.length === 2 && req.method === 'POST') { if (parts.length === 2 && req.method === 'POST') {
if (!doors.allow(clientOf(req))) throw new RoomError('Too many rooms opened from here just now; try again later.', 429); if (!doors.allow(clientOf(req))) throw new RoomError('Too many rooms opened from here just now; try again later.', 429);
const body = await readBody(req); const body = await readBody(req);
const { room, seat } = rooms.create(String(body.name ?? ''), Number(body.size ?? game.minSeats)); const { room, seat, token } = rooms.create(String(body.name ?? ''), Number(body.size ?? game.minSeats));
return send(res, 201, { seat: seat.id, token: seat.token, view: rooms.view(room, seat.id) }); return send(res, 201, { seat: seat.id, token, view: rooms.view(room, seat.id) });
} }
const room = rooms.get(parts[2] ?? ''); const room = rooms.get(parts[2] ?? '');
@@ -187,8 +187,8 @@ async function handle(req: IncomingMessage, res: ServerResponse): Promise<void>
const token = typeof body.token === 'string' && body.token ? body.token : undefined; const token = typeof body.token === 'string' && body.token ? body.token : undefined;
if (action === 'join') { if (action === 'join') {
if (!doors.allow(clientOf(req))) throw new RoomError('Too many seats taken from here just now; try again later.', 429); if (!doors.allow(clientOf(req))) throw new RoomError('Too many seats taken from here just now; try again later.', 429);
const seat = rooms.join(room, String(body.name ?? '')); const { seat, token: minted } = rooms.join(room, String(body.name ?? ''));
return send(res, 200, { seat: seat.id, token: seat.token, view: rooms.view(room, seat.id) }); return send(res, 200, { seat: seat.id, token: minted, view: rooms.view(room, seat.id) });
} }
if (action === 'bot') { if (action === 'bot') {
rooms.addBot(room, token); rooms.addBot(room, token);
+35 -22
View File
@@ -4,7 +4,7 @@
// then the round resolves at once, bots included. Nothing here knows the // then the round resolves at once, bots included. Nothing here knows the
// game beyond the GameSpec it is given. // game beyond the GameSpec it is given.
import { randomBytes } from 'node:crypto'; import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
import { SPECTATOR, type GameSpec, type SeatId } from '../../src/lib/game/spec'; import { SPECTATOR, type GameSpec, type SeatId } from '../../src/lib/game/spec';
import type { ChatLine, RoomView } from '../../src/lib/net/view'; import type { ChatLine, RoomView } from '../../src/lib/net/view';
import type { LedgerLine, Store } from './store'; import type { LedgerLine, Store } from './store';
@@ -17,10 +17,15 @@ export { SPECTATOR };
export interface Seat { export interface Seat {
id: SeatId; id: SeatId;
name: string; name: string;
token: string; /** SHA-256 of the seat's token, hex; empty for a bot. */
tokenHash: string;
bot: boolean; bot: boolean;
} }
export function hashToken(token: string): string {
return createHash('sha256').update(token).digest('hex');
}
export interface Room<State> { export interface Room<State> {
id: string; id: string;
size: number; size: number;
@@ -122,28 +127,29 @@ export class Rooms<State, Input> {
return this.rooms.size; return this.rooms.size;
} }
/** The seat a token unlocks. */ /** The seat a token unlocks, compared by hash in constant time. */
seatOf(room: Room<State>, token: string | undefined): Seat { seatOf(room: Room<State>, token: string | undefined): Seat {
const seat = room.seats.find((s) => !s.bot && s.token === token); if (!token) throw new RoomError('That token opens no seat at this game.', 403);
const given = Buffer.from(hashToken(token), 'hex');
const seat = room.seats.find((s) => !s.bot && s.tokenHash.length === given.length * 2 && timingSafeEqual(Buffer.from(s.tokenHash, 'hex'), given));
if (!seat) throw new RoomError('That token opens no seat at this game.', 403); if (!seat) throw new RoomError('That token opens no seat at this game.', 403);
return seat; return seat;
} }
create(name: string, size: number): { room: Room<State>; seat: Seat } { create(name: string, size: number): { room: Room<State>; seat: Seat; token: string } {
const { minSeats } = this.game; const { minSeats } = this.game;
if (!Number.isInteger(size) || size < minSeats || size > this.maxSeats) { if (!Number.isInteger(size) || size < minSeats || size > this.maxSeats) {
throw new RoomError(`A table seats ${minSeats} to ${this.maxSeats} players.`); throw new RoomError(`A table seats ${minSeats} to ${this.maxSeats} players.`);
} }
let id = newCode(); let id = newCode();
while (this.rooms.has(id)) id = newCode(); while (this.rooms.has(id) || this.store.exists(id)) id = newCode();
const room: Room<State> = { id, size, createdAt: Date.now(), seats: [], state: null, pending: {}, chat: [], seq: 0, updatedAt: Date.now() }; const room: Room<State> = { id, size, createdAt: Date.now(), seats: [], state: null, pending: {}, chat: [], seq: 0, updatedAt: Date.now() };
this.rooms.set(id, room); this.rooms.set(id, room);
this.commit(room, { t: 'room', id, seats: size, createdAt: room.createdAt }); this.commit(room, { t: 'room', id, seats: size, createdAt: room.createdAt });
const seat = this.sit(room, name, false); return { room, ...this.sit(room, name, false) };
return { room, seat };
} }
join(room: Room<State>, name: string): Seat { join(room: Room<State>, name: string): { seat: Seat; token: string } {
if (room.state) throw new RoomError('The game has begun; no more seats are taken.', 409); if (room.state) throw new RoomError('The game has begun; no more seats are taken.', 409);
return this.sit(room, name, false); return this.sit(room, name, false);
} }
@@ -157,23 +163,27 @@ export class Rooms<State, Input> {
this.commit(room, { t: 'start', seed: newSeed(), rules: this.game.currentRules }); this.commit(room, { t: 'start', seed: newSeed(), rules: this.game.currentRules });
} }
/** The host seats a bot in an empty chair. */
addBot(room: Room<State>, token: string | undefined): Seat { addBot(room: Room<State>, token: string | undefined): Seat {
this.seatOf(room, token); const host = this.seatOf(room, token);
if (host.id !== room.seats[0]?.id) throw new RoomError('Only the player who opened the table may seat a bot.', 403);
if (room.state) throw new RoomError('The game has begun; no more seats are taken.', 409); if (room.state) throw new RoomError('The game has begun; no more seats are taken.', 409);
const taken = new Set(room.seats.map((s) => s.name.toLowerCase())); const taken = new Set(room.seats.map((s) => s.name.toLowerCase()));
const pool = this.game.botNames.filter((n) => !taken.has(n.toLowerCase())); const pool = this.game.botNames.filter((n) => !taken.has(n.toLowerCase()));
const name = pool[Math.floor(Math.random() * pool.length)] ?? 'The Construct'; const name = pool[Math.floor(Math.random() * pool.length)] ?? 'The Construct';
return this.sit(room, name, true); return this.sit(room, name, true).seat;
} }
private sit(room: Room<State>, rawName: string, bot: boolean): Seat { /** Take the next free seat. The token is returned once and never stored; the ledger keeps its hash. */
private sit(room: Room<State>, rawName: string, bot: boolean): { seat: Seat; token: string } {
if (room.seats.length >= room.size) throw new RoomError('Every seat at this table is taken.', 409); if (room.seats.length >= room.size) throw new RoomError('Every seat at this table is taken.', 409);
const name = cleanName(rawName); const name = cleanName(rawName);
if (room.seats.some((s) => s.name.toLowerCase() === name.toLowerCase())) throw new RoomError('Another player here already has that name.', 409); if (room.seats.some((s) => s.name.toLowerCase() === name.toLowerCase())) throw new RoomError('Another player here already has that name.', 409);
const id = this.game.seatIds.find((s) => !room.seats.some((taken) => taken.id === s))!; const id = this.game.seatIds.find((s) => !room.seats.some((taken) => taken.id === s))!;
const seat: Seat = { id, name, token: bot ? '' : newId(18), bot }; const token = bot ? '' : newId(18);
this.commit(room, { t: 'seat', id: seat.id, name, token: seat.token, bot }); const seat: Seat = { id, name, tokenHash: bot ? '' : hashToken(token), bot };
return seat; this.commit(room, { t: 'seat', id: seat.id, name, tokenHash: seat.tokenHash, bot });
return { seat, token };
} }
/** The host sends a bot away before the game begins. People leave by not coming back. */ /** The host sends a bot away before the game begins. People leave by not coming back. */
@@ -186,17 +196,17 @@ export class Rooms<State, Input> {
this.commit(room, { t: 'unseat', id: seat.id }); this.commit(room, { t: 'unseat', id: seat.id });
} }
/** Record a seat's move; resolve the round once nobody else is awaited. */ /** Record a seat's move on the ledger, so a restart keeps it; resolve the round once nobody else is awaited. */
submit(room: Room<State>, token: string | undefined, raw: unknown): void { submit(room: Room<State>, token: string | undefined, raw: unknown): void {
const seat = this.seatOf(room, token); const seat = this.seatOf(room, token);
if (!room.state) throw new RoomError('The game has not begun: seats are still empty.', 409); if (!room.state) throw new RoomError('The game has not begun: seats are still empty.', 409);
if (this.game.over(room.state)) throw new RoomError('The game is over.', 409); if (this.game.over(room.state)) throw new RoomError('The game is over.', 409);
if (!this.waitingOn(room).includes(seat.id)) throw new RoomError('It is not your move.', 409); if (!this.waitingOn(room).includes(seat.id)) throw new RoomError('It is not your move.', 409);
room.pending[seat.id] = this.game.cleanInput(raw); const input = this.game.cleanInput(raw);
room.updatedAt = Date.now(); const why = this.game.validate?.(room.state, seat.id, input);
room.seq += 1; if (why) throw new RoomError(why);
this.commit(room, { t: 'input', at: Date.now(), id: seat.id, input });
if (this.waitingOn(room).length === 0) this.resolve(room); if (this.waitingOn(room).length === 0) this.resolve(room);
this.notify(room);
} }
private resolve(room: Room<State>): void { private resolve(room: Room<State>): void {
@@ -295,7 +305,10 @@ export class Rooms<State, Input> {
case 'over': case 'over':
break; break;
case 'seat': case 'seat':
room.seats.push({ id: line.id, name: line.name, token: line.token, bot: line.bot }); room.seats.push({ id: line.id, name: line.name, tokenHash: line.tokenHash ?? (line.token ? hashToken(line.token) : ''), bot: line.bot });
break;
case 'input':
room.pending[line.id] = this.game.cleanInput(line.input);
break; break;
case 'unseat': case 'unseat':
room.seats = room.seats.filter((s) => s.id !== line.id); room.seats = room.seats.filter((s) => s.id !== line.id);
@@ -321,7 +334,7 @@ export class Rooms<State, Input> {
const room: Room<State> = { id, size: first.seats, createdAt: first.createdAt, seats: [], state: null, pending: {}, chat: [], seq: lines.length, updatedAt: first.createdAt }; const room: Room<State> = { id, size: first.seats, createdAt: first.createdAt, seats: [], state: null, pending: {}, chat: [], seq: lines.length, updatedAt: first.createdAt };
for (const line of lines) { for (const line of lines) {
this.apply(room, line); this.apply(room, line);
if (line.t === 'turn') room.updatedAt = line.at; if (line.t === 'turn' || line.t === 'input') room.updatedAt = line.at;
} }
return room; return room;
} }
+9 -1
View File
@@ -8,7 +8,10 @@ import type { SeatId } from '../../src/lib/game/spec';
export type LedgerLine = export type LedgerLine =
| { t: 'room'; id: string; seats: number; createdAt: number } | { t: 'room'; id: string; seats: number; createdAt: number }
| { t: 'seat'; id: SeatId; name: string; token: string; bot: boolean } /** A seat holds only the hash of its token; the token itself goes once to the browser that earned it. Ledgers written before hashing carry `token` and are read once more. */
| { t: 'seat'; id: SeatId; name: string; tokenHash?: string; token?: string; bot: boolean }
/** One seat's move for the round in progress, so a restart keeps it. The turn line that follows carries every input again. */
| { t: 'input'; at: number; id: SeatId; input: unknown }
/** A bot sent away by the host before the game began; its seat id is free again. */ /** A bot sent away by the host before the game began; its seat id is free again. */
| { t: 'unseat'; id: SeatId } | { t: 'unseat'; id: SeatId }
/** Ledgers from before revisions were recorded resolve under rules 1. */ /** Ledgers from before revisions were recorded resolve under rules 1. */
@@ -41,6 +44,11 @@ export class Store {
.map((l) => JSON.parse(l) as LedgerLine); .map((l) => JSON.parse(l) as LedgerLine);
} }
/** Whether a ledger exists on disk, loaded or not; a code must not be reissued over one. */
exists(roomId: string): boolean {
return existsSync(this.path(roomId));
}
roomIds(): string[] { roomIds(): string[] {
return readdirSync(this.dir) return readdirSync(this.dir)
.filter((f) => f.endsWith('.jsonl')) .filter((f) => f.endsWith('.jsonl'))
+1 -1
View File
@@ -46,7 +46,7 @@
</ul> </ul>
{#if !room.spectating} {#if !room.spectating}
<div class="ways"> <div class="ways">
{#if seatFree} {#if seatFree && room.isHost}
<button type="button" class="quiet" onclick={() => room.addBot()}>Seat a bot</button> <button type="button" class="quiet" onclick={() => room.addBot()}>Seat a bot</button>
{/if} {/if}
{#if room.isHost} {#if room.isHost}
+3 -4
View File
@@ -87,9 +87,8 @@ export const game: GameSpec<State, Input> = {
view: (state, viewer) => (viewer === SPECTATOR ? structuredClone(state) : structuredClone(state)), 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. // 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) }), botInput: (state, seat) => ({ pick: 1 + Math.floor(random(state.rng + state.rounds.length * 7919 + state.seats.indexOf(seat)) * HIGHEST) }),
cleanInput: (raw) => { cleanInput: (raw) => ({ pick: Number((raw as { pick?: unknown })?.pick) }),
const pick = Number((raw as { pick?: unknown })?.pick); validate: (_state, _seat, input) =>
return { pick: Number.isInteger(pick) && pick >= 1 && pick <= HIGHEST ? pick : 1 }; 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 nameOf: (state, seat) => state.players[seat]?.name ?? seat
}; };
+7
View File
@@ -4,6 +4,11 @@
// inputs at a time, ask who still has to move, and hand each viewer the // 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 // 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. // 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. */ /** A seat at the table: a single letter from GameSpec.seatIds. */
export type SeatId = string; export type SeatId = string;
@@ -43,6 +48,8 @@ export interface GameSpec<State, Input> {
botInput(state: State, seat: SeatId): Input; botInput(state: State, seat: SeatId): Input;
/** Only the shapes the engine understands get through; the engine validates the rest. */ /** Only the shapes the engine understands get through; the engine validates the rest. */
cleanInput(raw: unknown): Input; 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. */ /** A seat's name from the state, for the hall and the chronicle. */
nameOf(state: State, seat: SeatId): string; nameOf(state: State, seat: SeatId): string;
} }