v0.8.0.10 — playtest fixes: clearance rulings, the log, the map, and a save file
From the first two multiplayer playtests of v0.8.0.9, each traced before fixing. The engine: - A train on a card BEHIND the one departing no longer triggers a clearance ruling or an opposite-direction bar (#26). Reproduced from the exported save: X15 was held over X18 behind it, and X18 then collided into the full Whistle Post. Games in progress holding a ruling the engine no longer asks for will not resume (28 of 40 recorded four-seat games); shipped as is at Jesse's call. - `mainlineModified` carries the card's previous kind, so the log can say what a Realignment converted (#27). The screen: - The turn chart and the Division map name the player whose move is on screen while bot turns replay, not the live actor (#25). - The owning player's name is no longer outlined by the turn arrow's stroke, which made it unreadable (#24). - A Mainline card flashes on the map when a Realignment changes it (#28). - The history is held back with the board and revealed step by step, instead of arriving whole while the board is still catching up (#29). - A ruling made by holding the office reads "Superintendent Player X" (#30), and no line names a player twice (#31). - A seated player can download their own game as a save file: the play page's Save replay button, fed by GET /api/save?token=… (#32). The StartOS action cannot do this — an action result is text only. Closes #24 Closes #25 Closes #26 Closes #27 Closes #28 Closes #29 Closes #30 Closes #31 Closes #32 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017nnuCv8UodHucFfx3LWEoX
This commit is contained in:
co-authored by
Claude Opus 5
parent
76c6e103b3
commit
4adf149ba5
@@ -309,3 +309,71 @@ describe('steps reach a seated player — TODO #13', () => {
|
||||
assert.ok(seen > 0, 'no steps reached a push, so this proved nothing');
|
||||
});
|
||||
});
|
||||
|
||||
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(' | ')}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user