From 7485d50b8db15880d3d5998886895b7a370a0f95 Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Wed, 23 Sep 2026 10:32:25 -0400 Subject: [PATCH] Duels record the rules revision they began under CURRENT_RULES is stamped on the start line and the game state, and a ledger replays under the revision it recorded (1 when it has none), so a future fix that changes how a past turn resolves can keep the old path for old duels. The reports desk skill now says when to bump it: only when the deploy gate flags ledgers. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_0141G6xqLeNRYEtviLWSB5Up --- .claude/skills/waving-hands-reports/SKILL.md | 13 +++++++++---- README.md | 4 +++- deploy/replay-ledgers.ts | 2 +- server/src/rooms.ts | 8 ++++---- server/src/store.ts | 3 ++- src/lib/game/duel.svelte.ts | 1 + src/lib/game/state.ts | 17 ++++++++++++++++- src/lib/game/view.test.ts | 9 ++++++++- 8 files changed, 44 insertions(+), 13 deletions(-) diff --git a/.claude/skills/waving-hands-reports/SKILL.md b/.claude/skills/waving-hands-reports/SKILL.md index b9011f3..3f43dec 100644 --- a/.claude/skills/waving-hands-reports/SKILL.md +++ b/.claude/skills/waving-hands-reports/SKILL.md @@ -61,10 +61,15 @@ to hear his players' voices; never compress their words into a table. ruling; ask him and hold the reply). 4. **Fix under house discipline.** The engine is deterministic and every deploy replays all production ledgers against the server - (`deploy/verify-ledgers.sh`). A fix that changes how a past turn - resolves will fail that gate for the ledgers it touches; when that - happens, show Eric which ledgers diverge and let him decide whether - to ship. Add a test in `src/lib/game/engine.test.ts` pinning the + (`deploy/verify-ledgers.sh`). Run it before deciding how to ship: + - Gate passes: the fix diverges from no ledger. Ship it ungated. + - Gate flags ledgers: the fix changes how an already-played turn + resolves. Bump `CURRENT_RULES` in `src/lib/game/state.ts`, add the + entry to its doc block, keep the old path behind + `state.rules < N`, and pin BOTH paths with tests (the legacy one by + passing `rules` to `createGame`). Old ledgers then replay as their + players saw them; new duels get the fix. + Either way add a test in `src/lib/game/engine.test.ts` pinning the corrected behaviour. Deploy with `deploy/deploy.sh 159.203.98.71`. 5. **Reply.** `bash deploy/report-reply.sh 159.203.98.71 "text"`. Pass the id as ONE clean argument. Write to the PLAYER: name what you diff --git a/README.md b/README.md index 0b6998c..f2dda1d 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,9 @@ the other wizard moves. An empty seat can be given to the bot. Each turn, choose hand, pick which completed spell (if any) each hand casts and at whom, and reveal. The rules are at `/rules`, and a walk through the page with screenshots at `/guide`. A room's link opened without a seat is the Peanut Gallery: a read-only view showing only what every wizard could see. -Seated wizards can talk to the table; the talk is a ledger line like any other. The Report +Seated wizards can talk to the table; the talk is a ledger line like any other. A duel records the rules revision it began under (`CURRENT_RULES` in `state.ts`), so a fix that +changes how a past turn resolves can keep the old path for old ledgers; the deploy gate says +when that is needed. The Report button in a room's masthead files a bug report pinned to the room and the turn, kept in `feedback.jsonl` beside the ledgers; `deploy/pull-reports.sh` mirrors them locally and `deploy/report-reply.sh` answers one, and the reply appears under the report in the player's hall. diff --git a/deploy/replay-ledgers.ts b/deploy/replay-ledgers.ts index d83237a..aa53731 100644 --- a/deploy/replay-ledgers.ts +++ b/deploy/replay-ledgers.ts @@ -38,7 +38,7 @@ async function main(): Promise { try { for (const line of lines) { if (line.t === 'seat') seats.push(line); - else if (line.t === 'start') state = createGame(Object.fromEntries(seats.map((s) => [s.id, s.name])), line.seed); + else if (line.t === 'start') state = createGame(Object.fromEntries(seats.map((s) => [s.id, s.name])), line.seed, line.rules ?? 1); else if (line.t === 'turn' && state) { state = resolveTurn(state, line.inputs as Record); turns += 1; diff --git a/server/src/rooms.ts b/server/src/rooms.ts index f2ec664..67f6b8e 100644 --- a/server/src/rooms.ts +++ b/server/src/rooms.ts @@ -7,7 +7,7 @@ import { randomBytes } from 'node:crypto'; import { chooseBotTurn } from '../../src/lib/game/bot'; import { resolveTurn } from '../../src/lib/game/resolve'; import { GESTURES, type Gesture } from '../../src/lib/game/spells'; -import { createGame, inDuel, SPECTATOR, type GameState, type TurnInput, type WizardId } from '../../src/lib/game/state'; +import { CURRENT_RULES, createGame, inDuel, SPECTATOR, type GameState, type TurnInput, type WizardId } from '../../src/lib/game/state'; import { viewFor, type ChatLine, type RoomView } from '../../src/lib/game/view'; import type { LedgerLine, Store } from './store'; @@ -142,7 +142,7 @@ export class Rooms { if (seat.id !== room.seats[0]?.id) throw new RoomError('Only the wizard who opened the table may begin the duel.', 403); if (room.state) throw new RoomError('The duel has already begun.', 409); if (room.seats.length < 2) throw new RoomError('A duel needs at least two wizards.', 409); - this.commit(room, { t: 'start', seed: newSeed() }); + this.commit(room, { t: 'start', seed: newSeed(), rules: CURRENT_RULES }); } addBot(room: Room, token: string | undefined): Seat { @@ -160,7 +160,7 @@ export class Rooms { if (room.seats.some((s) => s.name.toLowerCase() === name.toLowerCase())) throw new RoomError('Another wizard here already has that name.', 409); const seat: Seat = { id: SEAT_IDS[room.seats.length], name, token: bot ? '' : newId(18), bot }; this.commit(room, { t: 'seat', id: seat.id, name, token: seat.token, bot }); - if (room.seats.length === room.size) this.commit(room, { t: 'start', seed: newSeed() }); + if (room.seats.length === room.size) this.commit(room, { t: 'start', seed: newSeed(), rules: CURRENT_RULES }); return seat; } @@ -279,7 +279,7 @@ function apply(room: Room, line: LedgerLine): void { room.seats.push({ id: line.id, name: line.name, token: line.token, bot: line.bot }); break; case 'start': - room.state = createGame(Object.fromEntries(room.seats.map((s) => [s.id, s.name])), line.seed); + room.state = createGame(Object.fromEntries(room.seats.map((s) => [s.id, s.name])), line.seed, line.rules ?? 1); break; case 'turn': if (!room.state) return; diff --git a/server/src/store.ts b/server/src/store.ts index a36b6b5..3cd9608 100644 --- a/server/src/store.ts +++ b/server/src/store.ts @@ -9,7 +9,8 @@ import type { TurnInput, WizardId } from '../../src/lib/game/state'; export type LedgerLine = | { t: 'room'; id: string; seats: number; createdAt: number } | { t: 'seat'; id: WizardId; name: string; token: string; bot: boolean } - | { t: 'start'; seed: number } + /** Ledgers from before revisions were recorded resolve under rules 1. */ + | { t: 'start'; seed: number; rules?: number } | { t: 'turn'; at: number; inputs: Record } /** Written after the turn that ends the duel, for anything that reads ledgers without the engine. */ | { t: 'over'; at: number; winner: WizardId | null; reason: string } diff --git a/src/lib/game/duel.svelte.ts b/src/lib/game/duel.svelte.ts index 7b4d7fc..9e2adbb 100644 --- a/src/lib/game/duel.svelte.ts +++ b/src/lib/game/duel.svelte.ts @@ -179,6 +179,7 @@ function loadSaved(): SavedDuel | null { // Saves at this version may lack fields that came later; the engine expects them present. saved.state.seats ??= Object.keys(saved.state.wizards); saved.state.lessonsShown ??= []; + saved.state.rules ??= 1; if (saved.state.lastTurn) { saved.state.lastTurn.events ??= []; saved.state.lastTurn.lessons ??= []; diff --git a/src/lib/game/state.ts b/src/lib/game/state.ts index b920a1b..e3c05c7 100644 --- a/src/lib/game/state.ts +++ b/src/lib/game/state.ts @@ -129,7 +129,21 @@ export interface Outcome { reason: string; } +/** + * The rules revision a duel resolves under. A duel keeps the revision it began + * with, so a ledger replays to what its players saw even after the resolver + * changes. Bump this only when a fix changes how an already-played turn would + * resolve, which the deploy gate (deploy/verify-ledgers.sh) shows by flagging + * ledgers; a fix that diverges from no ledger ships without a bump. Keep the + * old path behind `state.rules < N` and pin both with tests. + * + * 1 Bartle's text as first implemented (2026-09-22). + */ +export const CURRENT_RULES = 1; + export interface GameState { + /** The revision this duel resolves under; see CURRENT_RULES. */ + rules: number; turn: number; /** Seats in table order; every wizard in play, whether still standing or not. */ seats: WizardId[]; @@ -235,9 +249,10 @@ function makeWizard(id: WizardId, name: string): WizardState { }; } -export function createGame(names: Record, seed = Date.now()): GameState { +export function createGame(names: Record, seed = Date.now(), rules = CURRENT_RULES): GameState { const seats = Object.keys(names); return { + rules, turn: 0, seats, wizards: Object.fromEntries(seats.map((id) => [id, makeWizard(id, names[id])])), diff --git a/src/lib/game/view.test.ts b/src/lib/game/view.test.ts index 4c50300..fb6761d 100644 --- a/src/lib/game/view.test.ts +++ b/src/lib/game/view.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { SPECTATOR, createGame } from './state'; +import { CURRENT_RULES, SPECTATOR, createGame } from './state'; import { viewFor } from './view'; /** Three wizards; B's first gestures were hidden from C alone. */ @@ -30,3 +30,10 @@ describe('the view a room sends', () => { expect(view.log.map((l) => l.text)).toEqual(['Nothing else happened.']); }); }); + +describe('the rules revision', () => { + it('is stamped on a new duel and kept from a ledger that recorded one', () => { + expect(createGame({ A: 'Aldric', B: 'Bryn' }).rules).toBe(CURRENT_RULES); + expect(createGame({ A: 'Aldric', B: 'Bryn' }, 1, 1).rules).toBe(1); + }); +});