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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0141G6xqLeNRYEtviLWSB5Up
This commit is contained in:
Eric Wagoner
2026-09-23 10:32:25 -04:00
co-authored by Claude Fable 5.1
parent 8eca078043
commit 7485d50b8d
8 changed files with 44 additions and 13 deletions
+9 -4
View File
@@ -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 <reportId> <status> "text"`.
Pass the id as ONE clean argument. Write to the PLAYER: name what you
+3 -1
View File
@@ -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.
+1 -1
View File
@@ -38,7 +38,7 @@ async function main(): Promise<void> {
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<WizardId, TurnInput>);
turns += 1;
+4 -4
View File
@@ -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;
+2 -1
View File
@@ -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<WizardId, TurnInput> }
/** 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 }
+1
View File
@@ -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 ??= [];
+16 -1
View File
@@ -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<WizardId, string>, seed = Date.now()): GameState {
export function createGame(names: Record<WizardId, string>, 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])])),
+8 -1
View File
@@ -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);
});
});