Nine reports from the Day 1-2 playtest of v0.8.0.12. One moved a car, one was a rule working correctly with nothing on screen to say so, and the rest are things the table could not see. The real bug: the Division Yard chips stayed lit and clickable while the board was catching up. `renderActions` puts the action list away while the queue is behind — a move offered against a position that has already moved on is a move made blind — but the make-up wiring sat outside that guard. A chip was clicked during a bot's make-up, a coach left the yard, and the train ended up with three cars: a real intent submitted against a board several moves stale. The chips now follow the queue like every other control, and the yard COUNTS are drawn from the shown board rather than the live game — they were the one panel still reporting a future the player had not been shown. The Office Area picker had a button per opponent and none for yourself, so the one player who could not reach their own district was the player waiting on everybody else. Your own seat is in the row now, and the row is ordered by SEAT, west to east as the Division map draws it, rather than by join order — sorted from the Frame's own `seat` on every render, so it rotates with Employee Rotation instead of having to be told. §5's handover of the Fedora rode on `actorChanged`, which is turn bookkeeping and which `record()` drops as noise, so the one moment it carried that a player needed went past in silence. It is its own event now, narrated and announced. The phase keeps its name: the Supervisor Shift refreshes every Laborer and Porter EVERY Stage and the Fedora moves only every third. A collision now names whose Office it was and who paid the 5 Revenue, which rode in a separate `revenueChanged`; a Mainline collision is phrased differently because §10 makes it the Superintendent's. Passengers, reported as a bug and ruled not one after replaying the save: the Depot's capacity and modifiers were fine, and §6.3 stocking wants a LOADED coach out of the Division Yard, which held none while six sat in Classification. The shortage stays — running out is part of the game, the same ruling Gitea#2 got — but the blocked panel says so now instead of the action being silently absent. Smaller: "working left" is "working eastward" in the make-up panel and the New Train tip, because the map runs west to east and the table does not; the history panel keeps 90 lines instead of 60 in the same 230px box. `git diff v0.8.0.12..v0.8.0.13 -- src/engine/` is NOT empty this time: `events.ts` declares `superintendentChanged` and `advance.ts` emits it. Both are additive — `check()`, `legal.ts` and every predicate are untouched, and events are derived by replaying a save rather than stored — so no once-legal move became illegal and games in progress resume. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DmdqqCNoiqE7GBo6wthBnR
407 lines
19 KiB
TypeScript
407 lines
19 KiB
TypeScript
/**
|
|
* THE WATCHABLE TABLE — v0.8.0, Gitea#20 / TODO #13, #15, #18.
|
|
*
|
|
* One shared, ordered presentation of everyone else's turns, on a seated player's own screen. The
|
|
* design is `docs/plans/jitsi-common-board.md` § v0.8.0; this file is its tests.
|
|
*
|
|
* Starting with ATTRIBUTION, because the caption row and the history panel both read these lines
|
|
* and a line that does not say who acted is useless on a screen built to answer "what did they
|
|
* just do?".
|
|
*/
|
|
|
|
import { describe, it } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
|
|
import { applyIntent } from '../src/engine/apply.ts';
|
|
import { legalActions } from '../src/engine/legal.ts';
|
|
import type { GameConfig, PlayerIndex } from '../src/engine/state.ts';
|
|
import { fromMultiplayerSave, newGame, newMultiplayerGame, submit } from '../src/web/game.ts';
|
|
import { currentActor } from '../src/web/game.ts';
|
|
import { applyPublicDelta } from '../src/sim/public-delta.ts';
|
|
import { publicSnapshot } from '../src/sim/view.ts';
|
|
import type { PublicFrame } from '../src/sim/view.ts';
|
|
import { takeSteps } from '../src/sim/display-step.ts';
|
|
import { createSession } from '../src/server/session.ts';
|
|
import { kindOf } from '../src/sim/pacing.ts';
|
|
|
|
const config: GameConfig = {
|
|
mode: 'competitive',
|
|
days: 5,
|
|
minCombinedRevenue: 0,
|
|
maxCollisionsPerDay: 0,
|
|
maxCollisionsTotal: 0,
|
|
pvpCardsAllowed: false,
|
|
optionalRules: {
|
|
reducedVisibility: false,
|
|
employeeRotation: false,
|
|
emergencyToolbox: false,
|
|
},
|
|
};
|
|
|
|
/**
|
|
* The four events a switching turn is made of. Every one of them used to arrive in the shared log
|
|
* unattributed: `record()` (`web/game.ts`) prefixes a line with the player's name only when the
|
|
* event itself carries `player`, and these four were the only events in their class that did not
|
|
* — `cardDrawn`, `cardPlayed`, `cardDiscarded`, `carPlacedOnTrain`, `loadStarted`, `loadCompleted`,
|
|
* `flyingSwitch` and `localOpsOptionChosen` all did. So a switching turn read as an attributed
|
|
* bracket around anonymous contents:
|
|
*
|
|
* Player Alice chose to switch ← attributed
|
|
* CREW moved (1,2) → (1,3) — 4 of 6 ← whose train?
|
|
* Player Alice finished Local Operations ← attributed
|
|
*
|
|
* Measured 2026-09-09 and fixed with the feature that reads them, not filed.
|
|
*/
|
|
const SWITCHING_EVENTS = ['trayMoved', 'carsCoupled', 'carsDropped', 'consistSorted'] as const;
|
|
|
|
/** How each of those four reads in the log, so the assertions can find them by text. */
|
|
const SWITCHING_LINE = /^Player .+ (moved (Train |the local crew)|coupled \d+ car|set out |used the SMALL YARD)/;
|
|
|
|
describe('switching is attributed — TODO #13', () => {
|
|
it('every switching event carries the player who acted', () => {
|
|
/**
|
|
* Driven by PREFERRING switch moves rather than taking the first legal action, because bot
|
|
* switching is clustered rather than spread: two of the three published replays contain no
|
|
* `switch.move` at all, so a game driven by `options[0]` can finish without ever exercising
|
|
* this. The counter below then guards against the test passing vacuously.
|
|
*/
|
|
let seen = 0;
|
|
for (const seed of [1917398, 191056, 4242]) {
|
|
const game = newMultiplayerGame(seed, config, ['Alice', 'Bob', 'Carol']);
|
|
for (let i = 0; i < 800; i++) {
|
|
const actor = currentActor(game);
|
|
if (actor === null) break;
|
|
const options = legalActions(game.state, actor);
|
|
if (options.length === 0) break;
|
|
const move = options.find((o) => o.type.startsWith('switch.') && o.type !== 'switch.end');
|
|
const chosen = move ?? options.find((o) => o.type === 'localOps.choose') ?? options[0]!;
|
|
|
|
// Read the events this intent produces before applying it for real, so the assertion sees
|
|
// exactly what `record()` will be handed.
|
|
const preview = applyIntent(structuredClone(game.state), actor, chosen);
|
|
if (preview.ok) {
|
|
for (const e of preview.events) {
|
|
if ((SWITCHING_EVENTS as readonly string[]).includes(e.type)) {
|
|
assert.ok(
|
|
'player' in e,
|
|
`${e.type} carries no player, so the log cannot say whose crew it was`,
|
|
);
|
|
assert.equal(
|
|
(e as { player: PlayerIndex }).player,
|
|
actor,
|
|
`${e.type} names the wrong player`,
|
|
);
|
|
seen++;
|
|
}
|
|
}
|
|
}
|
|
if (!submit(game, chosen)) break;
|
|
}
|
|
}
|
|
assert.ok(seen > 0, 'no switching event was produced, so this test proved nothing');
|
|
});
|
|
|
|
it('reads as a player action in the log, not as anonymous plain text', () => {
|
|
let lines = 0;
|
|
for (const seed of [1917398, 4242]) {
|
|
const game = newMultiplayerGame(seed, config, ['Alice', 'Bob', 'Carol']);
|
|
for (let i = 0; i < 800; i++) {
|
|
const actor = currentActor(game);
|
|
if (actor === null) break;
|
|
const options = legalActions(game.state, actor);
|
|
if (options.length === 0) break;
|
|
const move = options.find((o) => o.type.startsWith('switch.') && o.type !== 'switch.end');
|
|
if (!submit(game, move ?? options.find((o) => o.type === 'localOps.choose') ?? options[0]!)) break;
|
|
}
|
|
|
|
for (const line of game.log) {
|
|
// The old wording. `uncapitalise` deliberately leaves an acronym alone (`^[A-Z][a-z]` only),
|
|
// so "CREW moved" and "SMALL YARD —" would have survived the prefix and read as
|
|
// "Player Alice CREW moved …". Both were reworded to compose.
|
|
assert.doesNotMatch(
|
|
line.text,
|
|
/^CREW moved|^SMALL YARD —/,
|
|
`an unattributed switching line survived: ${line.text}`,
|
|
);
|
|
if (SWITCHING_LINE.test(line.text)) {
|
|
assert.equal(line.tone, 'act', `a switching line must read as somebody's move: ${line.text}`);
|
|
lines++;
|
|
}
|
|
}
|
|
}
|
|
assert.ok(lines > 0, 'no switching line reached the log, so this test proved nothing');
|
|
});
|
|
});
|
|
|
|
describe('the display-step collector — TODO #13', () => {
|
|
it('emits one step per accepted intent plus one per automatic phase, in order', () => {
|
|
const game = newMultiplayerGame(1917398, config, ['Alice', 'Bob', 'Carol']);
|
|
let accepted = 0;
|
|
for (let i = 0; i < 120; i++) {
|
|
const actor = currentActor(game);
|
|
if (actor === null) break;
|
|
const options = legalActions(game.state, actor);
|
|
if (options.length === 0) break;
|
|
const move = options.find((o) => o.type.startsWith('switch.') && o.type !== 'switch.end');
|
|
if (!submit(game, move ?? options.find((o) => o.type === 'localOps.choose') ?? options[0]!)) break;
|
|
accepted++;
|
|
}
|
|
assert.ok(accepted > 30, `only ${accepted} intents accepted — this proved little`);
|
|
|
|
const steps = takeSteps(game.display);
|
|
/**
|
|
* TWO KINDS OF STEP SINCE TODO #18: one per accepted intent, and one per automatic phase that
|
|
* did anything. So the count is no longer `accepted` — but every intent must still have exactly
|
|
* one step, which is the invariant that matters.
|
|
*/
|
|
const byIntent = steps.filter((s) => s.cause !== 'phase');
|
|
const byPhase = steps.filter((s) => s.cause === 'phase');
|
|
assert.equal(byIntent.length, accepted, 'one step per accepted intent, no more and no fewer');
|
|
assert.ok(byPhase.length > 0, 'no phase produced a step — TODO #18 is not being served');
|
|
steps.forEach((s, i) => {
|
|
assert.equal(s.seq, i, 'sequence numbers must be dense and in order');
|
|
assert.equal(s.protocolVersion, 1);
|
|
assert.ok(kindOf(s.cause), `step ${i} carries a cause pacing cannot classify`);
|
|
// A phase is nobody's move; an intent is always somebody's.
|
|
assert.equal(s.player === null, s.cause === 'phase', `step ${i} disagrees about who acted`);
|
|
assert.equal(s.seat === null, s.cause === 'phase');
|
|
});
|
|
assert.equal(takeSteps(game.display).length, 0, 'draining must empty the collector');
|
|
});
|
|
|
|
it('a rejected intent produces no step', () => {
|
|
const game = newMultiplayerGame(4242, config, ['Alice', 'Bob', 'Carol']);
|
|
takeSteps(game.display);
|
|
// Somebody else's turn: refused before the engine is touched, so nothing to present.
|
|
const notMyTurn = ((currentActor(game) ?? 0) + 1) % 3;
|
|
assert.equal(submit(game, { type: 'draw.end' }, notMyTurn as PlayerIndex), false);
|
|
assert.equal(takeSteps(game.display).length, 0, 'a refused intent must not be presented');
|
|
});
|
|
|
|
it('the step deltas reconstruct the public board exactly', () => {
|
|
const game = newMultiplayerGame(1917398, config, ['Alice', 'Bob', 'Carol']);
|
|
let held: PublicFrame | null = null;
|
|
for (let i = 0; i < 150; i++) {
|
|
const actor = currentActor(game);
|
|
if (actor === null) break;
|
|
const options = legalActions(game.state, actor);
|
|
if (options.length === 0) break;
|
|
const move = options.find((o) => o.type.startsWith('switch.') && o.type !== 'switch.end');
|
|
if (!submit(game, move ?? options.find((o) => o.type === 'localOps.choose') ?? options[0]!)) break;
|
|
for (const s of takeSteps(game.display)) held = applyPublicDelta(held, s.frame);
|
|
}
|
|
assert.deepEqual(held, publicSnapshot(game.state), 'the animated board drifted from the real one');
|
|
});
|
|
|
|
/**
|
|
* THE PROPERTY THAT IS CURRENTLY FREE AND MUST STAY THAT WAY.
|
|
*
|
|
* `fromSave`/`fromMultiplayerSave` rebuild a game with `applyIntent` + `record` + `drain` rather
|
|
* than `submit`, so a resumed server does not re-emit the whole game as steps and burn the
|
|
* sequence. The plan expected this to need an explicit guard. It does not — but move a replay
|
|
* path onto `submit()` and it silently becomes a real bug, which is why this is pinned.
|
|
*/
|
|
it('replaying a save emits no steps at all', () => {
|
|
const game = newMultiplayerGame(1917398, config, ['Alice', 'Bob', 'Carol']);
|
|
for (let i = 0; i < 80; i++) {
|
|
const actor = currentActor(game);
|
|
if (actor === null) break;
|
|
const options = legalActions(game.state, actor);
|
|
if (options.length === 0) break;
|
|
if (!submit(game, options[0]!)) break;
|
|
}
|
|
assert.ok(game.history.length > 20, 'need a real history to replay');
|
|
|
|
const rebuilt = fromMultiplayerSave(game.seed, config, ['Alice', 'Bob', 'Carol'], game.history);
|
|
assert.equal(
|
|
rebuilt.game.display.steps.length,
|
|
0,
|
|
'a replay re-emitted the whole game as display steps',
|
|
);
|
|
assert.equal(rebuilt.game.display.seq, 0, 'a replay burned display sequence numbers');
|
|
});
|
|
|
|
it('solitaire collects the same way multiplayer does', () => {
|
|
// The standing design direction: solitaire is a special case of multiplayer, not a second
|
|
// implementation. Both go through one `submit()`, so this needs no separate code path — and
|
|
// that is exactly what makes TODO #18 fall out of TODO #13's mechanism.
|
|
const game = newGame(4242);
|
|
let accepted = 0;
|
|
for (let i = 0; i < 60; i++) {
|
|
const actor = currentActor(game);
|
|
if (actor === null) break;
|
|
const options = legalActions(game.state, actor);
|
|
if (options.length === 0) break;
|
|
if (!submit(game, options[0]!)) break;
|
|
accepted++;
|
|
}
|
|
assert.ok(accepted > 10, 'the solitaire game did not get going');
|
|
const collected = takeSteps(game.display);
|
|
assert.equal(
|
|
collected.filter((s) => s.cause !== 'phase').length,
|
|
accepted,
|
|
'solitaire must collect a step per intent too',
|
|
);
|
|
// And solitaire is where TODO #18 lives — its phases must earn beats on the same path.
|
|
assert.ok(collected.some((s) => s.cause === 'phase'), 'solitaire got no phase steps');
|
|
});
|
|
});
|
|
|
|
describe('steps reach a seated player — TODO #13', () => {
|
|
it('never replays the opening bot turns at the first client to connect', () => {
|
|
/**
|
|
* `buildSession` runs `driveBotTurns()` at construction, so with bots ahead of you in the order
|
|
* the game has already moved before anybody can connect. Those steps must be DROPPED, not
|
|
* queued: a connecting client's `publicReset` is the board as it stands after those very moves,
|
|
* so replaying them onto it would draw positions the game had already left.
|
|
*
|
|
* Found by review 2026-09-09 rather than by a failing test, which is why this one exists.
|
|
*/
|
|
const session = createSession(1917398, config, ['Alice', 'Bob', 'Carol'], [1, 2]);
|
|
const push = session.connect(0 as PlayerIndex);
|
|
assert.ok(push.publicReset, 'a connecting client needs a baseline');
|
|
assert.equal(push.steps, undefined, 'the connect push must carry no steps at all');
|
|
|
|
// And the first real broadcast must carry only what THIS move produced — nothing older.
|
|
const option = push.menu?.options[0];
|
|
assert.ok(option, 'seat 0 should have something to do');
|
|
const r = session.intent(0 as PlayerIndex, 1, option);
|
|
assert.ok(r.accepted);
|
|
const steps = [...r.pushes.values()][0]?.steps ?? [];
|
|
assert.ok(steps.length > 0, 'the move produced no steps');
|
|
/**
|
|
* The first step delivered must be THIS seat's move — not a bot's, which is what a replayed
|
|
* opening turn would look like. The sequence does NOT restart at 0: `takeSteps` empties the
|
|
* collector without rewinding the counter, so the first thing a client sees may be seq 14. That
|
|
* is fine and deliberate — what 0.8.1's gap detection needs is monotonic and dense, not
|
|
* zero-based.
|
|
*/
|
|
assert.equal(steps[0]!.player, 0, 'the first delivered step was not the move just made');
|
|
assert.equal(steps[0]!.cause, option.type);
|
|
steps.forEach((st, i) => {
|
|
if (i > 0) assert.equal(st.seq, steps[i - 1]!.seq + 1, 'sequence must stay dense');
|
|
});
|
|
});
|
|
|
|
|
|
it('every seat gets the same public steps, and a connect gets a baseline to merge onto', () => {
|
|
const session = createSession(1917398, config, ['Alice', 'Bob', 'Carol'], [1, 2]);
|
|
|
|
const connected = session.connect(0 as PlayerIndex);
|
|
assert.ok(connected.publicReset, 'a connecting client needs a baseline for its step queue');
|
|
|
|
let seen = 0;
|
|
for (let i = 0; i < 60; i++) {
|
|
const menu = session.connect(0 as PlayerIndex).menu;
|
|
const option = menu?.options[0];
|
|
if (!option) break;
|
|
const r = session.intent(0 as PlayerIndex, i, option);
|
|
if (!r.accepted) break;
|
|
const pushes = [...r.pushes.values()];
|
|
if (pushes.length === 0) continue;
|
|
const first = pushes[0]!.steps ?? [];
|
|
if (first.length === 0) continue;
|
|
seen += first.length;
|
|
for (const p of pushes) {
|
|
assert.deepEqual(p.steps, first, 'every seat must receive the identical public steps');
|
|
}
|
|
}
|
|
assert.ok(seen > 0, 'no steps reached a push, so this proved nothing');
|
|
});
|
|
});
|
|
|
|
describe('the Fedora passing is visible (playtest 2026-09-16)', () => {
|
|
it('names the new Superintendent in the history at the Stage it happens', async () => {
|
|
const { newMultiplayerGame, currentActor, submit } = await import('../src/web/game.ts');
|
|
const { legalActions } = await import('../src/engine/legal.ts');
|
|
|
|
/**
|
|
* It used to ride on `actorChanged`, which `record()` drops as turn bookkeeping — so the one
|
|
* moment that event meant something never reached a player. Driven far enough to cross a shift
|
|
* boundary (Stages 3, 6, 9, 12) rather than asserted on a hand-built event, because the point is
|
|
* that a real game produces the line.
|
|
*/
|
|
const game = newMultiplayerGame(1917398, config, ['Alice', 'Bob', 'Carol']);
|
|
for (let i = 0; i < 900; i++) {
|
|
const actor = currentActor(game);
|
|
if (actor === null) break;
|
|
const options = legalActions(game.state, actor);
|
|
if (options.length === 0) break;
|
|
if (!submit(game, options.find((o) => o.type === 'localOps.choose') ?? options[0]!)) break;
|
|
if (game.state.clock.stage > 3 || game.state.clock.day > 1) break;
|
|
}
|
|
|
|
const handover = game.log.filter((l) => /SUPERINTENDENT — the Fedora passes to/.test(l.text));
|
|
assert.ok(handover.length > 0, 'the game crossed a shift change and the log never said so');
|
|
assert.match(handover[0]!.text, /Alice|Bob|Carol/, 'the handover did not name the new Superintendent');
|
|
});
|
|
});
|
|
|
|
describe('the log says who acted, once, and in what capacity (Gitea#30, #31)', () => {
|
|
it('never names a player twice — no "Player Jesse player 0 finished …"', async () => {
|
|
const { newMultiplayerGame, currentActor, submit } = await import('../src/web/game.ts');
|
|
const { legalActions } = await import('../src/engine/legal.ts');
|
|
|
|
const game = newMultiplayerGame(1917398, config, ['Alice', 'Bob', 'Carol']);
|
|
for (let i = 0; i < 400; i++) {
|
|
const actor = currentActor(game);
|
|
if (actor === null) break;
|
|
const options = legalActions(game.state, actor);
|
|
if (options.length === 0) break;
|
|
if (!submit(game, options.find((o) => o.type === 'localOps.choose') ?? options[0]!)) break;
|
|
}
|
|
|
|
assert.ok(game.log.length > 50, 'the game barely ran, so this proved little');
|
|
for (const line of game.log) {
|
|
// `record()` prefixes the acting player's NAME; a narration that also named them read
|
|
// "Player Alice player 0 finished Local Operations" (playtest, 2026-09-15).
|
|
assert.doesNotMatch(
|
|
line.text,
|
|
/\bplayer \d+\b/i,
|
|
`a line still carries a bare player index: ${line.text}`,
|
|
);
|
|
}
|
|
});
|
|
|
|
it('attributes a clearance ruling to the office, not to the seat\'s own turn', async () => {
|
|
const { newMultiplayerGame, drain, submit } = await import('../src/web/game.ts');
|
|
const { areaOf } = await import('../src/engine/apply.ts');
|
|
|
|
const game = newMultiplayerGame(7, config, ['Alice', 'Bob', 'Carol']);
|
|
const s = game.state;
|
|
const area = areaOf(s, 0);
|
|
|
|
// A westbound train at seat 0's Office, and another westbound AHEAD of it — west of the Office —
|
|
// which is §8.1's fourth condition and the Superintendent's to rule on (see Gitea#26).
|
|
s.trays.set('departing', {
|
|
id: 'departing', trainNumber: 15, trainIsExtra: true, engineAt: 0, consist: [],
|
|
direction: 'west', facing: 'w',
|
|
position: { at: 'grid', seat: 0, coord: area.officeCoord }, movesUsed: 0,
|
|
} as never);
|
|
area.adOccupancy.push('departing');
|
|
const office = s.division.nodes.findIndex((n) => n.kind === 'office' && n.seat === 0);
|
|
const card = s.division.nodes.findIndex((n, i) => i < office && n.kind === 'mainline');
|
|
const node = s.division.nodes[card];
|
|
assert.equal(node?.kind, 'mainline');
|
|
s.trays.set('ahead', {
|
|
id: 'ahead', trainNumber: 9, trainIsExtra: false, engineAt: 0, consist: [],
|
|
direction: 'west', facing: 'w', position: { at: 'mainline', index: card }, movesUsed: 0,
|
|
} as never);
|
|
if (node?.kind === 'mainline') {
|
|
node.transits.push({ tray: 'ahead', stagesRemaining: 2, stagesTotal: 2, direction: 'west' });
|
|
}
|
|
|
|
s.clock.phase = 'mainline';
|
|
drain(game);
|
|
assert.equal(s.clock.pendingDecision?.kind, 'clearance', 'no ruling was called for, so nothing was tested');
|
|
|
|
const before = game.log.length;
|
|
assert.ok(submit(game, { type: 'mainline.clearance', allow: false }, s.clock.superintendent));
|
|
const said = game.log.slice(before).map((l) => l.text);
|
|
assert.ok(
|
|
said.some((text) => text.startsWith('Superintendent Player ')),
|
|
`a ruling did not read as the office's: ${said.join(' | ')}`,
|
|
);
|
|
});
|
|
});
|