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
+72
-1
@@ -1141,7 +1141,12 @@ describe('the history says WHY a train moved, and says it truthfully', () => {
|
||||
|
||||
// A train ahead of it in the same Subdivision, running the SAME way — §8.1's fourth condition,
|
||||
// which is the Superintendent's call rather than an absolute bar.
|
||||
const ahead = s.division.nodes.findIndex((n) => n.kind === 'mainline');
|
||||
//
|
||||
// AHEAD MEANS EAST OF THE OFFICE for this eastbound train. This used to take the FIRST Mainline card
|
||||
// in the Division, which is west of the Office — behind the train — and still expected a ruling,
|
||||
// which is exactly the fault Gitea#26 reported. The card is now one the train would actually follow.
|
||||
const office = s.division.nodes.findIndex((n) => n.kind === 'office' && n.seat === 0);
|
||||
const ahead = s.division.nodes.findIndex((n, i) => i > office && n.kind === 'mainline');
|
||||
const node = s.division.nodes[ahead];
|
||||
assert.equal(node?.kind, 'mainline');
|
||||
s.trays.set('ahead', {
|
||||
@@ -1511,3 +1516,69 @@ describe('an Extra starts where the player puts it (Gitea#4)', () => {
|
||||
assert.equal(check(s, 0, at), 'NO_EXTRA_PENDING');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('§8.1 counts only trains AHEAD of the one departing (Gitea#26)', () => {
|
||||
/**
|
||||
* REPORTED from playtesting v0.8.0.9: two westbound Extras, X15 at an Office and X18 still crossing
|
||||
* the card to its EAST. The Superintendent was asked to rule on X15 against X18 — a train behind it —
|
||||
* and holding X15 kept the Whistle Post's only A/D track full, so X18 arrived into it and was
|
||||
* destroyed. Reproduced by replaying the exported save; the positions below are that situation in a
|
||||
* one-seat Division, where every Office is a Whistle Post and the Subdivision spans them all.
|
||||
*/
|
||||
const setup = (occupant: { direction: 'east' | 'west'; side: 'east' | 'west'; number: number }) => {
|
||||
const s = game(7, { days: 5 });
|
||||
const area = areaOf(s, 0);
|
||||
const office = s.division.nodes.findIndex((n) => n.kind === 'office' && n.seat === 0);
|
||||
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,
|
||||
});
|
||||
area.adOccupancy.push('departing');
|
||||
const card = s.division.nodes.findIndex((n, i) =>
|
||||
n.kind === 'mainline' && (occupant.side === 'east' ? i > office : i < office));
|
||||
const node = s.division.nodes[card];
|
||||
assert.equal(node?.kind, 'mainline');
|
||||
s.trays.set('other', {
|
||||
id: 'other', trainNumber: occupant.number, trainIsExtra: true, engineAt: 0, consist: [],
|
||||
direction: occupant.direction, facing: occupant.direction === 'east' ? 'e' : 'w',
|
||||
position: { at: 'mainline', index: card }, movesUsed: 0,
|
||||
});
|
||||
if (node?.kind === 'mainline') {
|
||||
node.transits.push({ tray: 'other', stagesRemaining: 2, stagesTotal: 2, direction: occupant.direction });
|
||||
}
|
||||
s.clock.phase = 'mainline';
|
||||
return s;
|
||||
};
|
||||
|
||||
it('does not put a same-direction train BEHIND the departing one to the Superintendent', () => {
|
||||
const s = setup({ direction: 'west', side: 'east', number: 18 });
|
||||
const r = advance(s);
|
||||
assert.ok(!r.events.some((e) => e.type === 'clearanceRequested'), 'a train behind was put to the Superintendent');
|
||||
assert.ok(
|
||||
r.events.some((e) => e.type === 'trainHighballed' && e.trainNumber === 15),
|
||||
'the departing train did not highball with nothing ahead of it',
|
||||
);
|
||||
assert.ok(!r.events.some((e) => e.type === 'trainsDestroyed'), 'a train was destroyed');
|
||||
});
|
||||
|
||||
it('does not bar a departure over an opposite-direction train BEHIND it, which is moving away', () => {
|
||||
const s = setup({ direction: 'east', side: 'east', number: 18 });
|
||||
const r = advance(s);
|
||||
assert.ok(
|
||||
r.events.some((e) => e.type === 'trainHighballed' && e.trainNumber === 15),
|
||||
'a train moving away behind it held the departure',
|
||||
);
|
||||
});
|
||||
|
||||
it('still puts a same-direction train AHEAD to the Superintendent', () => {
|
||||
const s = setup({ direction: 'west', side: 'west', number: 18 });
|
||||
const r = advance(s);
|
||||
assert.ok(
|
||||
r.events.some((e) => e.type === 'clearanceRequested' && e.trainId === 'departing'),
|
||||
'a train the departing one would follow was not put to the Superintendent',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -254,3 +254,41 @@ describe('which piles a step moved', () => {
|
||||
assert.deepEqual(changedPiles(null, publicSnapshot(s)), []);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a Mainline card that changed under the players (Gitea#28)', () => {
|
||||
it('names the node a Realignment converted, and nothing else', async () => {
|
||||
const { changedDivisionCards } = await import('../src/sim/public-delta.ts');
|
||||
const { REALIGNMENTS } = await import('../src/engine/content.ts');
|
||||
|
||||
const s = newState(4242);
|
||||
const before = publicSnapshot(s);
|
||||
|
||||
// Realignment converts a card to another kind (`content.ts`'s table). Applied to the state directly:
|
||||
// what is being tested is the DETECTOR, not the play that reaches it — which needs the card in hand,
|
||||
// the draw option taken and no train on the card.
|
||||
const at = s.division.nodes.findIndex((n) => n.kind === 'mainline' && REALIGNMENTS.some((r) => r.from === n.card));
|
||||
assert.ok(at >= 0, 'no Mainline card in this Division can be realigned at all');
|
||||
const node = s.division.nodes[at];
|
||||
assert.equal(node?.kind, 'mainline');
|
||||
if (node?.kind === 'mainline') {
|
||||
node.card = REALIGNMENTS.find((r) => r.from === node.card)!.to;
|
||||
}
|
||||
const after = publicSnapshot(s);
|
||||
|
||||
assert.deepEqual(changedDivisionCards(before, after), [at], 'the realigned card was not the one reported');
|
||||
assert.deepEqual(changedDivisionCards(after, after), [], 'an unchanged Division reported a change');
|
||||
assert.deepEqual(changedDivisionCards(null, after), [], 'a first board has nothing to compare against');
|
||||
});
|
||||
|
||||
it('says nothing when only the trains on a card moved', async () => {
|
||||
const { changedDivisionCards } = await import('../src/sim/public-delta.ts');
|
||||
const s = newState(1917398);
|
||||
const before = publicSnapshot(s);
|
||||
const at = s.division.nodes.findIndex((n) => n.kind === 'mainline');
|
||||
const node = s.division.nodes[at];
|
||||
if (node?.kind === 'mainline') {
|
||||
node.transits.push({ tray: 'tray1', stagesRemaining: 1, stagesTotal: 1, direction: 'east' });
|
||||
}
|
||||
assert.deepEqual(changedDivisionCards(before, publicSnapshot(s)), [], 'a train arriving flashed the card');
|
||||
});
|
||||
});
|
||||
|
||||
+75
-1
@@ -15,7 +15,7 @@ import { currentActor, newMultiplayerGame, submit } from '../src/web/game.ts';
|
||||
import { publicSnapshot } from '../src/sim/view.ts';
|
||||
import { takeSteps } from '../src/sim/display-step.ts';
|
||||
import type { DisplayStep } from '../src/sim/display-step.ts';
|
||||
import { createStepQueue } from '../src/web/step-queue.ts';
|
||||
import { actorOnScreen, createStepQueue } from '../src/web/step-queue.ts';
|
||||
import { DWELL } from '../src/sim/pacing.ts';
|
||||
|
||||
const config: GameConfig = {
|
||||
@@ -278,3 +278,77 @@ describe('the step queue', () => {
|
||||
assert.equal(q.showing(), null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('whose move the screen is showing (Gitea#25)', () => {
|
||||
const step = (player: number | null) => ({ player }) as DisplayStep;
|
||||
const queue = (behind: number, busy: boolean, showing: DisplayStep | null) => ({
|
||||
behind: () => behind,
|
||||
busy: () => busy,
|
||||
showing: () => showing,
|
||||
});
|
||||
|
||||
it('names the live actor once the board has caught up', () => {
|
||||
assert.deepEqual(actorOnScreen(queue(0, false, step(2)), 0), { actor: 0, replaying: false });
|
||||
});
|
||||
|
||||
it('names the player of the step on screen while the board is behind — not the live actor', () => {
|
||||
// One human (seat 0) against bots: the live game already waits on seat 0 while bot 2's moves replay.
|
||||
assert.deepEqual(actorOnScreen(queue(3, true, step(2)), 0), { actor: 2, replaying: true });
|
||||
});
|
||||
|
||||
it('keeps naming the last step while it is still on screen, after the counter reaches zero', () => {
|
||||
assert.deepEqual(actorOnScreen(queue(0, true, step(1)), 0), { actor: 1, replaying: true });
|
||||
});
|
||||
|
||||
it('names nobody for an automatic phase being shown', () => {
|
||||
assert.deepEqual(actorOnScreen(queue(2, true, step(null)), 0), { actor: null, replaying: true });
|
||||
});
|
||||
|
||||
it('falls back to the live actor before any step has been shown', () => {
|
||||
assert.deepEqual(actorOnScreen(queue(1, true, null), 3), { actor: 3, replaying: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('the log is held back with the board, and a changed card flashes (playtest, 2026-09-15)', () => {
|
||||
it('owes exactly the lines of the steps not yet shown', () => {
|
||||
const { steps } = realSteps(1917398, 400);
|
||||
const q = createStepQueue();
|
||||
q.reset(baseline(1917398));
|
||||
assert.equal(q.pendingLines(), 0, 'an empty queue holds nothing back');
|
||||
|
||||
q.push(steps);
|
||||
const owed = steps.reduce((n, s) => n + s.lines.length, 0);
|
||||
assert.equal(q.pendingLines(), owed, 'every queued step still owes its lines');
|
||||
|
||||
// Drive the clock as a render loop would; the debt falls monotonically and ends at nothing.
|
||||
let now = 0;
|
||||
let last = owed;
|
||||
for (let i = 0; i < 20_000 && q.busy(); i++) {
|
||||
q.advance(now);
|
||||
const left = q.pendingLines();
|
||||
assert.ok(left <= last, 'the held-back count grew while the board caught up');
|
||||
last = left;
|
||||
now += 50;
|
||||
}
|
||||
assert.equal(q.pendingLines(), 0, 'the board caught up but lines were still withheld');
|
||||
});
|
||||
|
||||
it('skipping reveals the whole log at once', () => {
|
||||
const { steps } = realSteps(1917398, 400);
|
||||
const q = createStepQueue();
|
||||
q.reset(baseline(1917398));
|
||||
q.push(steps);
|
||||
q.skip();
|
||||
assert.equal(q.pendingLines(), 0, 'Skip left lines withheld — the history would stay short');
|
||||
});
|
||||
|
||||
it('flashes nothing on an ordinary step', () => {
|
||||
// Realignment is rare in bot play, so this pins the quiet case: the map must not pulse at random.
|
||||
const { steps } = realSteps(1917398, 400);
|
||||
const q = createStepQueue();
|
||||
q.reset(baseline(1917398));
|
||||
q.push(steps.slice(0, 5));
|
||||
q.advance(0);
|
||||
assert.deepEqual(q.flashing(), [], 'a step that changed no Mainline card flashed one');
|
||||
});
|
||||
});
|
||||
|
||||
+9
-5
@@ -151,10 +151,14 @@ describe('the tally counts every event exactly once (Gitea#16)', () => {
|
||||
|
||||
it('splits Revenue into what was earned and what was given back', () => {
|
||||
// Reconciliation is the real assertion and it holds for any game, earned or not: gained minus
|
||||
// lost IS the score the engine kept. Seed 42 is named because it is one where Revenue actually
|
||||
// lost IS the score the engine kept. Seed 44 is named because it is one where Revenue actually
|
||||
// moves in both directions — it earns 1 and gives back 5 to a collision — so the two halves are
|
||||
// being told apart rather than both sitting at zero.
|
||||
for (const seed of [1, 7, 42]) {
|
||||
//
|
||||
// It was seed 42 until v0.8.0.10. That game's collision was the Superintendent holding a train over
|
||||
// one BEHIND it (Gitea#26); with the ruling gone the collision is too, and seed 42 now earns 5 and
|
||||
// loses nothing — a better game and a vacuous test. The seed moved, not the assertion.
|
||||
for (const seed of [1, 7, 44]) {
|
||||
const { state } = playKeepingEvents(seed);
|
||||
const me = state.tally.byPlayer[0]!;
|
||||
assert.equal(
|
||||
@@ -163,10 +167,10 @@ describe('the tally counts every event exactly once (Gitea#16)', () => {
|
||||
`seed ${seed}: gained minus lost does not reconcile with the score the engine kept`,
|
||||
);
|
||||
}
|
||||
const { state } = playKeepingEvents(42);
|
||||
const { state } = playKeepingEvents(44);
|
||||
const me = state.tally.byPlayer[0]!;
|
||||
assert.ok(me.revenueGained > 0, 'seed 42 earned nothing — the gained half is not being counted');
|
||||
assert.ok(me.revenueLost > 0, 'seed 42 lost nothing — the lost half is not being counted');
|
||||
assert.ok(me.revenueGained > 0, 'seed 44 earned nothing — the gained half is not being counted');
|
||||
assert.ok(me.revenueLost > 0, 'seed 44 lost nothing — the lost half is not being counted');
|
||||
});
|
||||
|
||||
it('records a Circus set-up as the one-off it is, not as a streak', () => {
|
||||
|
||||
@@ -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