v0.8.0.14 — the coaches that never come back, and a district that ends at its own sign
Six reports from the Day 2-3 playtest of v0.8.0.13. GAMES IN PROGRESS DO NOT SURVIVE THIS ONE. Modifiers are now bounded by the Limits, which makes a once-legal move illegal, so a save holding one is refused at that move: whistle-6945.day3.stage10 stops at intent 528 of 539. Jesse's call, knowing it strands the game on the box. The file is untouched and v0.8.0.13 still finishes it. The Sparrow running empty and Tom unable to unload his passengers are the same shortage from opposite ends, and both are the rules working as printed. §9.2 boarding discards the emptied coach into the CLASSIFICATION yard, detraining draws a fresh empty out of the DIVISION yard, and §2.2 sends Classification back only when the Division Yard runs bare — so coaches move one way. Measured over the save: sixteen in the Division Yard at setup, zero from Day 2 Stage 8 to the end, fifteen piled in Classification, the Division Yard steady at 46-47 freight cars with no prospect of going bare. Jesse's ruling is Gitea#2's: the shortage stays and the game says so. A train made up short now reports what its card wanted and why none is coming (`makeUpShort` — `trainNeedingCars` answered null for "done" and for "cannot be done" alike, so the phase moved on in silence); the yard panel warns while the condition lasts; the Depot's blocked panel was right all along. The modifier outside the Limits was working as designed and the design was Jesse's own call, now reversed. What decided it is what the board shows — a card beyond your own sign, in territory §8.1 and §10 reason about. The case that motivated the exemption was checked on the reported move rather than argued away: the Power Plant sat at (-1,3) against a sign at column 3 and two spots inside were free, legal and adjacent. Switching filled the history with coordinates — a line per move, plus one per mandatory coupling. It is still LOGGED in full; what the panel draws is the line saying somebody switched, the first move, work at an INDUSTRY (named, not a coordinate), the Small Yard sort, and a closing summary. The suppressed lines are still WRITTEN, marked `trace`: dropping them outright was the first attempt and the step-queue suite caught it, because dwellForStep pays nothing for a step that said nothing, so the board stopped replaying switching at all. The last move rides in the closing line rather than being kept in place — nothing knows a move was the last until the turn is over, by which time the line has been streamed to every client and cannot be revised. Two things fell out of reading those lines: every move ended with a tutorial sentence the opener already gives, and the move count said "of 6" with the six hardcoded, which is wrong on a night Stage. Make-up lines name their train — they all read "the train being made up", so looking back for train 10 found nothing under that name — and "a empty tank" is now "an empty tank". The Small Yard's options read as the train they would build instead of `[1,2,3,0]`; the one Jesse wanted was the first of five and unreadable. Two of those five were junk: bringing the last car to the end is the identity and would have spent a Move, and a two-car reversal duplicated its only real option. Both are filtered by the resulting order, not by the case that made them. A Small Yard may now put cars AHEAD of the engine, which was Jesse's own open question. Two sources disagreed and the design notes won: the v0.4.5 card text says the sort puts the engine at the nose, implications.md says "any order, including cars ahead of the engine". `engineAt` is optional on the intent, so older saves replay to the same train. The menu did not multiply — the engine is a separate short list against the consist as it stands, eight options for a four-car train rather than twenty. §8.2 needed no new code: badlyMadeUp is deliberately direction-free, so a PUSHING train is fit to run and only a broken-backed one is held. The button warns by asking that predicate rather than copying it, and immediately earned itself — every one of train 10's eight options is refused, the one asked for at the table included, because that train carries a caboose and each sort moves it off the rear. That is the right answer rather than a gap: the train is already made up, so every offer would break it, and the labels say which is which. A made-up order is always on the menu for a train that needs one, because "bring car k to the tail" is enumerated for every car and the caboose is one of them. Labels read WEST TO EAST, with the engine drawn as the board's own ◀ / ▶ arrow. "Front to back" is not a direction a table can read — which end is the front depends on which way the train points — and board-svg has reversed east-facing consists since v0.8.0, so the button now describes the same train as the picture. The Freight Agent, Porter and Laborer groups now say what the role is for, where the role is chosen. Tom reached for the Freight Agent to detrain passengers, which is a Porter's action in the Cargo phase; both halves were working and neither was visible. TODO closes #107 (the nose sort) and gains #108 (the coach ratchet, with the measurement, to revisit on a second game's data). 999 fast tests and 35 sim tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DmdqqCNoiqE7GBo6wthBnR
This commit is contained in:
co-authored by
Claude Opus 5
parent
ad277fb994
commit
a6657241de
+166
-3
@@ -12,9 +12,10 @@
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { applyIntent } from '../src/engine/apply.ts';
|
||||
import { applyIntent, areaOf } from '../src/engine/apply.ts';
|
||||
import { legalActions } from '../src/engine/legal.ts';
|
||||
import type { GameConfig, PlayerIndex } from '../src/engine/state.ts';
|
||||
import { turnOf } 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';
|
||||
@@ -55,7 +56,18 @@ const config: GameConfig = {
|
||||
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)/;
|
||||
const SWITCHING_LINE = /^Player .+ (moved (Train |the local crew)|coupled at |set out |used the SMALL YARD)/;
|
||||
|
||||
/**
|
||||
* The two tones a switching line may carry, and why attribution matters in BOTH.
|
||||
*
|
||||
* Since 2026-09-17 a plain move along your own track is written `trace`: the line still exists, so
|
||||
* its display step has narration to caption the board with and a dwell to be watched for, but the
|
||||
* history panel does not draw it (`web/game.ts` § inHistory). What must never happen either way is
|
||||
* the line failing to say whose crew it was — the caption is read by the whole table while the move
|
||||
* goes up, which is if anything the more public of the two places.
|
||||
*/
|
||||
const SWITCHING_TONES = ['act', 'trace'];
|
||||
|
||||
describe('switching is attributed — TODO #13', () => {
|
||||
it('every switching event carries the player who acted', () => {
|
||||
@@ -124,7 +136,10 @@ describe('switching is attributed — TODO #13', () => {
|
||||
`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}`);
|
||||
assert.ok(
|
||||
SWITCHING_TONES.includes(line.tone),
|
||||
`a switching line must read as somebody's move: ${line.text} (tone ${line.tone})`,
|
||||
);
|
||||
lines++;
|
||||
}
|
||||
}
|
||||
@@ -404,3 +419,151 @@ describe('the log says who acted, once, and in what capacity (Gitea#30, #31)', (
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* HOW MUCH SWITCHING REACHES THE HISTORY PANEL — Jesse's ruling, 2026-09-17, asked as a question
|
||||
* from the table: *"Does switching show up in history at all? Should it? … I don't think I want all
|
||||
* six moves showing up… maybe dropping off or picking up cars in industries should be recorded."*
|
||||
*
|
||||
* It all showed up. A six-Move turn wrote a line per move, every one of them a pair of coordinates,
|
||||
* and two players shunting pushed everything else off the panel. What stays is the line saying
|
||||
* somebody switched, work at an INDUSTRY, and the Small Yard sort.
|
||||
*
|
||||
* THE LINE IS STILL WRITTEN, MARKED `trace`. `dwellForStep` gives a step no dwell when it produced
|
||||
* no narration, so dropping these outright stopped the board replaying switching at all — the first
|
||||
* attempt at this did exactly that and the step-queue suite caught it. The tone is the seam: the
|
||||
* caption still has its text, the panel filters the tone out.
|
||||
*/
|
||||
describe('the history panel keeps the switching that matters', () => {
|
||||
const play = (seed: number, steps: number) => {
|
||||
const game = newMultiplayerGame(seed, config, ['Alice', 'Bob', 'Carol']);
|
||||
for (let i = 0; i < steps; 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;
|
||||
}
|
||||
return game;
|
||||
};
|
||||
|
||||
it('keeps the first move of a turn and traces the ones after it', () => {
|
||||
const game = play(1917398, 800);
|
||||
const moves = game.log.filter((l) => / moved (Train|the local crew)/.test(l.text));
|
||||
assert.ok(moves.length > 0, 'no crew move reached the log, so this proved nothing');
|
||||
|
||||
// The opener of a turn is the move that leaves `movesAllowed - 1` behind it, which the line
|
||||
// prints — "5 of 6 Moves left". Those are drawn; everything after them is caption-only.
|
||||
const opening = moves.filter((l) => / 5 of 6 Moves left/.test(l.text));
|
||||
const later = moves.filter((l) => !/ 5 of 6 Moves left/.test(l.text));
|
||||
assert.ok(opening.length > 0, 'no turn opened with a move, so this proved nothing');
|
||||
assert.ok(later.length > 0, 'no turn made a second move, so this proved nothing');
|
||||
assert.ok(
|
||||
opening.every((l) => l.tone !== 'trace'),
|
||||
`the first move of a switching turn was hidden: ${opening.find((l) => l.tone === 'trace')?.text}`,
|
||||
);
|
||||
assert.ok(
|
||||
later.every((l) => l.tone === 'trace'),
|
||||
`a move from the middle of a turn is still drawn: ${later.find((l) => l.tone !== 'trace')?.text}`,
|
||||
);
|
||||
// Every one keeps its text, because that is what captions the board as the move goes up.
|
||||
assert.ok(moves.every((l) => /^Player /.test(l.text)), 'a trace line lost its attribution');
|
||||
});
|
||||
|
||||
it('closes a switching turn with what it cost and where the crew was left', () => {
|
||||
/**
|
||||
* BUILT, NOT PLAYED — the driver above never submits `switch.end`: it finds a move or another
|
||||
* option every time, so 800 turns produced no closed switching turn at all and the assertion
|
||||
* would have been vacuous.
|
||||
*/
|
||||
const { game, trayId } = crewOnAnIndustry();
|
||||
assert.ok(submit(game, { type: 'switch.move', trayId, to: { row: 1, col: 2 }, reverse: false }));
|
||||
assert.ok(submit(game, { type: 'switch.end' }), 'the turn would not end');
|
||||
|
||||
const closing = game.log.filter((l) => / finished switching/.test(l.text));
|
||||
assert.equal(closing.length, 1, `expected one closing line, got ${closing.length}`);
|
||||
assert.notEqual(closing[0]!.tone, 'trace', 'the closing summary was hidden from the history');
|
||||
assert.match(
|
||||
closing[0]!.text,
|
||||
/1 of 6 Moves used, leaving .* at /,
|
||||
`the closing line did not say what it cost and where the crew was left: ${closing[0]!.text}`,
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* A crew standing on a Freight House with a loaded boxcar, and plain track to its east.
|
||||
*
|
||||
* BUILT RATHER THAN PLAYED. The bot prefers moves over couplings, so 2400 driven turns across
|
||||
* three seeds produced not one set-out at an industry, and none of them ever ended a switching
|
||||
* turn — a driver that cannot reach the case cannot test it.
|
||||
*/
|
||||
const crewOnAnIndustry = (): { game: ReturnType<typeof newGame>; trayId: string } => {
|
||||
const game = newGame(77);
|
||||
const area = areaOf(game.state, 0);
|
||||
const spot = { row: 1, col: 1 };
|
||||
const track = (over: object = {}) => ({
|
||||
geometry: { kind: 'track', geometry: 'straight' },
|
||||
baseOperationalRail: true,
|
||||
standing: [],
|
||||
standingWest: 0,
|
||||
facility: null,
|
||||
modifiers: [],
|
||||
enhancements: [],
|
||||
...over,
|
||||
});
|
||||
area.grid.set('1,1', track({
|
||||
geometry: { kind: 'facility', facility: 'freightHouse', axis: 'ew' },
|
||||
facility: {
|
||||
kind: 'freight', subtype: 'freightHouse',
|
||||
allows: { outbound: true, inbound: true },
|
||||
outboundBox: [], inboundBox: [],
|
||||
capacity: { outbound: 1, inbound: 1 },
|
||||
menAtWork: [null, null, null],
|
||||
industryTrack: { cars: [] },
|
||||
laborers: 1, porters: 0, usedThisStage: { laborers: 0, porters: 0 },
|
||||
},
|
||||
}) as never);
|
||||
area.grid.set('1,2', track() as never);
|
||||
|
||||
const trayId = game.state.freeTrays.pop()!;
|
||||
game.state.trays.set(trayId, {
|
||||
id: trayId, trainNumber: null, trainIsExtra: false, engineAt: 0,
|
||||
consist: [{ type: 'boxcar', loaded: true }],
|
||||
direction: 'east', facing: 'e', railFacing: 'e',
|
||||
position: { at: 'grid', seat: 0, coord: spot }, movesUsed: 0,
|
||||
} as never);
|
||||
game.state.clock.phase = 'localOps';
|
||||
game.state.clock.currentActor = 0;
|
||||
turnOf(game.state, 0).option = 'switch';
|
||||
return { game, trayId };
|
||||
};
|
||||
|
||||
it('keeps work at an industry, and drops the same move on plain track', () => {
|
||||
const { game, trayId } = crewOnAnIndustry();
|
||||
const plain = { row: 1, col: 2 };
|
||||
assert.ok(submit(game, { type: 'switch.dropCars', trayId, count: 1 }), 'the set-out was refused');
|
||||
const dropped = game.log.filter((l) => /set out/i.test(l.text));
|
||||
assert.equal(dropped.length, 1, `expected one set-out line, got ${dropped.length}`);
|
||||
assert.notEqual(
|
||||
dropped[0]!.tone,
|
||||
'trace',
|
||||
'work at an industry was hidden from the history — it is the point of switching',
|
||||
);
|
||||
assert.match(
|
||||
dropped[0]!.text,
|
||||
/at the Freight House/,
|
||||
`the line named a coordinate instead of the industry: ${dropped[0]!.text}`,
|
||||
);
|
||||
|
||||
// The same crew moving onto ordinary track is the noise this ruling was about.
|
||||
assert.ok(submit(game, { type: 'switch.move', trayId, to: plain, reverse: false }), 'the move was refused');
|
||||
const moved = game.log.filter((l) => / moved the local crew/.test(l.text));
|
||||
assert.equal(moved.length, 1, `expected one move line, got ${moved.length}`);
|
||||
// The FIRST move of a turn is kept, and this crew's first move is this one — so what is being
|
||||
// checked here is that it names the square by what stands on it rather than by its coordinates.
|
||||
assert.match(moved[0]!.text, /→ the Freight House|→ \(1,2\)/, `unexpected move line: ${moved[0]!.text}`);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user