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:
co-authored by
Claude Fable 5.1
parent
8eca078043
commit
7485d50b8d
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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 }
|
||||
|
||||
@@ -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
@@ -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])])),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user