v0.7.9.1 — the engine was right twice; the screen was not

Gitea#22: the Division map drew every westbound train in the wrong half of
its Mainline card. `regionOfTransit` counts from the end a train entered,
which is what the collision rules ask; the map wanted "which printed box,
left to right" and used the same number, so an eastbound train came out
right by luck and a westbound one came out mirrored. It cost a collision —
Train 3 was cleared to follow T5 and ran into TX17, which the picture had
drawn ahead of T5 rather than behind it. One mirror in `view.ts`, at the
boundary the map is drawn from; the collision rules are untouched.

Gitea#21: a second tank car would not come off at a refinery, and "Blocked
— why nothing is moving" answered by describing the refinery's green box.
The real answer was Train 3's printed rule — the Express works one freight
car per location — so the refusal was correct and the panel sent the player
to spend a Freight Agent action that could not have helped. No rule
changed. The panel now names the budget, asking the reducer's own
predicate so its words cannot drift from the rule.

Both were replayed from the saves attached to the issues and verified in
the exact position each report names. The map fix is proved by mutation:
reverting the mirror fails two tests, and making the renderer ignore the
region fails a third. Every existing region test ran eastbound, where the
mirror is the identity, which is why the bug survived them.

891 tests pass, up from 884.

Closes #21
Closes #22

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E3Qk7uresKCHksdZajXCLg
This commit is contained in:
Jesse.Markowitz
2026-09-07 11:41:23 -04:00
co-authored by Claude Opus 5
parent 6058f6c17e
commit 7ade60e21f
11 changed files with 412 additions and 21 deletions
+6
View File
@@ -50,3 +50,9 @@ __pycache__/
# one that is worth publishing. # one that is worth publishing.
/playtests/* /playtests/*
!/playtests/README.md !/playtests/README.md
# The Jitsi harness is NOT scrubbed — four of its files carry a real domain and real
# participant names (workspace AGENTS.local.md). Ignored so that a `git add -A` cannot sweep it
# into a history that would be permanently exposed if this repo is ever made public. When it is
# promoted in Phase 1, scrub it FIRST, then `git add -f tools/`.
tools/
+81
View File
@@ -19,6 +19,87 @@ page as `v0.1.0 · <sha> · <date>`, so what is deployed can always be identifie
--- ---
## 0.7.9.1 — 2026-09-07
Two playtest bugs from one session (seed 550943578). Both were reported as the game getting a rule
wrong; in both the engine was right and what failed was what the screen said about it.
### The map drew westbound trains in the wrong half of the card (Gitea#22)
Reported as a collision that hit the wrong train: "the display graphically showed train 17 further
west than train 5… I allowed train 3 and it collided w/train 17, not train 5 as expected."
`regionOfTransit` answers **how far along its crossing a train is**, counted from the end it
entered — everything still to run is region 0. That is the question the collision rules ask, both
directions share the one index space, and it was correct throughout. The Division map was asking a
different question with the same number: **which printed box, left to right.** East is right on
this map (Gitea#18), so for an eastbound train the two coincide by luck — it enters at the west end,
so "just entered" and "leftmost box" are the same box. A westbound train enters at the **east** end,
so its region 0 is the right-hand box, and using the travel index directly drew the whole card
mirrored.
Replayed from the attached save at intent 186, which is the position the ruling was made in: T5 was
a Stage from the far end (travel index 1) and TX17 had just entered behind it (index 0). Both
westbound, so TX17 was physically **east** of T5 — behind it, in the direction they had both come
from. The map drew TX17 at the left, which reads as further west, which reads as further ahead. Asked
whether Train 3 could follow Train 5 onto the card, the Superintendent said yes, and Train 3 entered
behind — into TX17, exactly where the rules had always had it.
**So the fix is one mirror in `view.ts`, at the boundary the map is drawn from, and the collision
rules are untouched.** `regionOfTransit` keeps its meaning. This is the same class of bug as the
consist row at the Whistle Post (seed 270861860), which came out mirrored for the same reason: a
number that means "distance run" used where the screen means "place".
The tooltip carried the same number and now says which way it is counted — "region 2 of 2, counted
west to east". Beside "2 Stages still to run" the bare number reads as a contradiction, and the
reporter quoted the tooltip as part of what misled them.
Proved by mutation rather than by assertion: reverting the mirror fails two tests in
`mainline-cards.test.ts`, and making the renderer ignore the region fails the SVG placement test in
`web.test.ts`. The existing region tests all ran eastbound, where the mirror is the identity, which
is why the bug survived them.
### A refusal the Blocked panel explained wrongly (Gitea#21)
Reported as "could not drop second tank car at refinery… I dropped the first tank car, but that was
all I was allowed to do."
Replayed from the attached save: the crew was **Train 3**, and the engine's answer was
`FREIGHT_WORKED_HERE`. Train 3 is the Express, which prints *"May drop or pick up one freight car at
every location"* — **the refusal was correct**, and the budget is per location rather than per turn,
so the same train may work another car at the next square it reaches. No rule changed.
What failed is that nothing said so. The player checked "Blocked — why nothing is moving" and got
`refinery 1,0 — green box empty — nothing to load (needs a Freight Agent action)`. That is a true
statement about the facility and has nothing to do with why the drop was refused — so it sent them
to spend a Freight Agent action that could not have helped. **A panel that answers the wrong
question is worse than one that stays silent**, because it looks like an answer. The rule was on the
train card's own tooltip, which is not where anyone looks when a button they expected is absent.
The panel now names it, and only when the crew still has a freight car it could otherwise set out —
a spent budget on a train with nothing to drop is blocking nothing, and this panel earns its keep by
staying short enough to read. It asks `freightBudgetLeft`, the same predicate the reducer refuses
on, through a new exported `freightRuleSpentHere`, so the words cannot drift from the rule.
Against the reporter's own save the panel now reads:
```
[waiting] refinery 1,0 — green box empty — nothing to load (needs a Freight Agent action)
[waiting] Train 3 at (0,1) — ONE FREIGHT CAR PER LOCATION — this train has already worked a
freight car on this square, so no more come off or on here until next turn.
```
### Both were verified against the saves attached to the issues
Not against a reconstruction. Each save was replayed through `fromSave` to the exact intent the
report names, and the fix checked in that state — which is what the process item added after v0.7.5
through v0.7.8 asks for, three releases that each reported the same bug fixed and each fixed
something that was not the reported fault.
891 tests pass, up from 884.
---
## 0.7.9 — 2026-08-30 ## 0.7.9 — 2026-08-30
Four pieces of feedback on the solitaire setup screen, plus the rules bug the second one exposed. Four pieces of feedback on the solitaire setup screen, plus the rules bug the second one exposed.
+38 -8
View File
@@ -43,13 +43,21 @@ Not items. Things that are true of every change, and that have gone wrong when s
- **Keep `CHANGELOG.md` current in the same change.** Commit messages stay high level; the - **Keep `CHANGELOG.md` current in the same change.** Commit messages stay high level; the
reasoning, the measurements and the things that turned out to be wrong live there. reasoning, the measurements and the things that turned out to be wrong live there.
30. **Close each Gitea issue by hand, with a comment naming the commit that fixed it** and, where the 30. **Close a Gitea issue with `Closes #<n>` in the commit — not by hand** (Jesse, 2026-08-30).
fix was not what the report implied, the ruling that decided it. Auto-closing leaves an issue with Gitea records the linking commit itself when it auto-closes, so a manual close adds nothing but a
no record of which commit or which release answered it. Done for every issue closed so far: #2, step, and the same rule is what the workspace `AGENTS.md` already prescribes.
#6, #8, #9 and #10 from v0.7.1; #3, #14, #15, #17 and #18 from v0.7.2 — including #15's REVERSAL
on review (the placement is legal; what was confirmed is that no train crosses the gap) and #14's **What auto-closing does NOT carry is a RULING, and that still has to be written by hand.** When
list of the ten unbuilt cards, so they do not vanish with the issue. The token and the API calls the fix was not what the report implied, comment on the issue before it closes, saying what was
are in the workspace's `AGENTS.local.md`. decided and why. That is the half worth keeping: Gitea#15 was REVERSED on review — the placement
is legal, and what was actually confirmed is that no train crosses the gap — and Gitea#14's
comment lists the ten unbuilt cards so they do not vanish along with the issue. A bare
`Closes #<n>` would have lost both.
This item said the opposite until 2026-08-30 ("close each issue by hand… auto-closing leaves no
record"), which was written from how the issues closed in v0.7.1 and v0.7.2 rather than from a
decision. The token and the API calls, for reading issues and for leaving a ruling, are in the
workspace's `AGENTS.local.md`.
37. **The wrapper release sequence, unchanged since 0.7.2 and worth following exactly.** Tag the app, 37. **The wrapper release sequence, unchanged since 0.7.2 and worth following exactly.** Tag the app,
fetch the tag into the wrapper's submodule, bump `current.ts` **in place** (the outgoing `up` has fetch the tag into the wrapper's submodule, bump `current.ts` **in place** (the outgoing `up` has
@@ -1721,11 +1729,33 @@ re-verified turn out to have been re-verified against a premise rather than agai
Closed items, kept because several are the only record of a ruling or a lesson. Newest first within Closed items, kept because several are the only record of a ruling or a lesson. Newest first within
each group. each group.
### Shipped through v0.7.9, from the queue ### Shipped through v0.7.9.1, from the queue
Closed items, newest first. Kept because several of them are the only record of a ruling or a lesson; Closed items, newest first. Kept because several of them are the only record of a ruling or a lesson;
the numbers stay so cross-references above and below still resolve. the numbers stay so cross-references above and below still resolve.
89. ~~**The map drew westbound trains in the wrong half of a Mainline card.**~~ — done 2026-09-07 in
v0.7.9.1, Gitea#22. `regionOfTransit` counts from the end a train ENTERED, which is what the
collision rules want; the map wanted "which printed box, left to right" and used the same number,
so every westbound card came out mirrored. It cost a collision: the Superintendent cleared Train 3
to follow T5 and it ran into TX17, which the picture had drawn ahead of T5 instead of behind it.
**Worth knowing:** the engine was right and only the picture lied, so the fix is one mirror in
`view.ts` at the drawing boundary — and every existing region test ran eastbound, where the mirror
is the identity, which is exactly why it survived them. The same shape as the mirrored consist row
at the Whistle Post (seed 270861860): a number meaning "distance run" used where the screen means
"place". **Ask of any new Frame field whether it is a distance or a position.**
90. ~~**The Blocked panel explained a refusal by describing something else entirely.**~~ — done
2026-09-07 in v0.7.9.1, Gitea#21. A second tank car would not come off at a refinery; the panel
said the refinery's green box was empty. The real answer was Train 3's printed rule — the Express
may work one freight car per location — so the refusal was correct and the panel sent the player
to spend a Freight Agent action that could not have helped. **The ruling: no rule changed, only
what the screen says about it.** The panel asks the reducer's own `freightBudgetLeft` through a
new exported `freightRuleSpentHere`, so its words cannot drift from the rule. **Worth knowing:**
a panel that answers the wrong question is worse than one that stays silent, because it looks
like an answer — and a rule that only lives on a card tooltip is invisible at the moment a player
notices a button is missing.
43. ~~**"Waiting on" said nobody while the game was stopped on the Superintendent.**~~ — done 43. ~~**"Waiting on" said nobody while the game was stopped on the Superintendent.**~~ — done
2026-08-30 in v0.7.9. `Frame.actor` carried `clock.currentActor`, null for the whole Mainline 2026-08-30 in v0.7.9. `Frame.actor` carried `clock.currentActor`, null for the whole Mainline
Phase, so all three interruptions (clearance, Yard Office, Red Flag) reported that the Division Phase, so all three interruptions (clearance, Yard Office, Red Flag) reported that the Division
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "station-master", "name": "station-master",
"version": "0.7.9", "version": "0.7.9.1",
"private": true, "private": true,
"type": "module", "type": "module",
"description": "Station Master — a railroad operations game", "description": "Station Master — a railroad operations game",
+16 -1
View File
@@ -607,7 +607,8 @@ function freightWorkedKey(trayId: TrayId, at: GridCoord): string {
return `${trayId}@${coordKey(at)}`; return `${trayId}@${coordKey(at)}`;
} }
const isFreight = (c: RollingStock): boolean => c.type !== 'coach' && c.type !== 'caboose'; /** Exported so the Blocked panel counts a freight car the same way the reducers do (Gitea#21). */
export const isFreight = (c: RollingStock): boolean => c.type !== 'coach' && c.type !== 'caboose';
/** /**
* IS THIS CAR CARRYING A LOAD? A CABOOSE NEVER IS, whatever its `loaded` flag says. * IS THIS CAR CARRYING A LOAD? A CABOOSE NEVER IS, whatever its `loaded` flag says.
@@ -657,6 +658,20 @@ function switchingRefusal(tray: CrewTray): RejectionCode | null {
return rulesOf(tray).noSwitching ? 'NO_SWITCHING' : null; return rulesOf(tray).noSwitching ? 'NO_SWITCHING' : null;
} }
/**
* WHETHER TRAINS 3/4's PRINTED RULE IS WHAT IS STOPPING THIS CREW WHERE IT STANDS (Gitea#21).
*
* Exported for the Blocked panel, which needs to say so — and asks `freightBudgetLeft`, the same
* predicate the reducer refuses on, rather than rebuilding the key for itself. `narrate.ts` cannot
* then drift from the rule it is describing, which is the whole premise of that panel.
*/
export function freightRuleSpentHere(s: GameState, player: PlayerIndex, trayId: TrayId): boolean {
const tray = s.trays.get(trayId);
if (!tray || !rulesOf(tray).oneFreightPerLocation) return false;
if (tray.position.at !== 'grid') return false;
return !freightBudgetLeft(s, player, tray, tray.position.coord, 1);
}
/** /**
* Charge freight cars against this train's per-location budget (trains 3/4). * Charge freight cars against this train's per-location budget (trains 3/4).
* *
+9 -1
View File
@@ -452,6 +452,14 @@ export function divisionSvg(nodes: DivisionView[], roster?: DivisionRoster | nul
* things the Division map is for — where a train is and which way it is going — and leaves the * things the Division map is for — where a train is and which way it is going — and leaves the
* cars to the tooltip and to the district. * cars to the tooltip and to the district.
*/ */
/**
* THE REGION NUMBER IS A PLACE ON THE MAP, NOT A DISTANCE RUN (Gitea#22).
*
* `view.ts` mirrors a westbound train before it gets here, so this counts boxes west to east
* for every train regardless of which way it is going — and the tooltip says so, because
* "region 2 of 2" beside "2 Stages still to run" reads as a contradiction otherwise. It is the
* second box from the west end; a westbound train in it has its whole crossing ahead of it.
*/
const chip = (t: NonNullable<Cell['trains']>[number], tx: number, ty: number, w: number): void => { const chip = (t: NonNullable<Cell['trains']>[number], tx: number, ty: number, w: number): void => {
const cars = t.cars ?? []; const cars = t.cars ?? [];
const arrow = t.facing === 'w' ? '\u25c0' : '\u25b6'; const arrow = t.facing === 'w' ? '\u25c0' : '\u25b6';
@@ -465,7 +473,7 @@ export function divisionSvg(nodes: DivisionView[], roster?: DivisionRoster | nul
: ''; : '';
out += `<g class="bs-train" data-tip="${esc(t.label)} \u2014 carrying ${esc(cars.join(', ') || 'no cars')}${ out += `<g class="bs-train" data-tip="${esc(t.label)} \u2014 carrying ${esc(cars.join(', ') || 'no cars')}${
cars.length ? ` (${loaded} loaded)` : '' cars.length ? ` (${loaded} loaded)` : ''
}${inRegion ? ` \u00b7 region ${(t.region ?? 0) + 1} of ${c.regions}${dir}` : ''}${esc(stages)}${ }${inRegion ? ` \u00b7 region ${(t.region ?? 0) + 1} of ${c.regions}, counted west to east${dir}` : ''}${esc(stages)}${
// What the card prints. A train on the Mainline is exactly where "why did that leave without // What the card prints. A train on the Mainline is exactly where "why did that leave without
// me?" gets asked, and EXPEDITED is the answer more often than not. // me?" gets asked, and EXPEDITED is the answer more often than not.
t.what ? `\n\n${esc(t.what)}` : '' t.what ? `\n\n${esc(t.what)}` : ''
+37 -1
View File
@@ -18,7 +18,7 @@
import { MAX_CONSIST } from '../engine/content.ts'; import { MAX_CONSIST } from '../engine/content.ts';
import { adTrackCount, coordKey, seatOf, turnOf } from '../engine/state.ts'; import { adTrackCount, coordKey, seatOf, turnOf } from '../engine/state.ts';
import type { GameState, GridCoord, PlayerIndex, RollingStock, SeatIndex, TrayId } from '../engine/state.ts'; import type { GameState, GridCoord, PlayerIndex, RollingStock, SeatIndex, TrayId } from '../engine/state.ts';
import { areaOf, canAdvanceLoad, canBoard, canDetrain, canStartLoad, facilityCarType, facilityCarTypes, laborersLeft, movesFor, passengerRefusal, portersLeft } from '../engine/apply.ts'; import { areaOf, canAdvanceLoad, canBoard, canDetrain, canStartLoad, facilityCarType, facilityCarTypes, freightRuleSpentHere, isFreight, laborersLeft, movesFor, passengerRefusal, portersLeft } from '../engine/apply.ts';
import type { GameEvent } from '../engine/events.ts'; import type { GameEvent } from '../engine/events.ts';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -746,6 +746,42 @@ export function impediments(s: GameState, player: PlayerIndex = 0): Impediment[]
* Only while switching, and only the cards actually in the crew's way: `movesFor` reports the * Only while switching, and only the cards actually in the crew's way: `movesFor` reports the
* squares the movement walk reached and refused, not every square on the board. * squares the movement walk reached and refused, not every square on the board.
*/ */
/**
* WHY A CAR WILL NOT COME OFF (Gitea#21).
*
* "I dropped the first tank car, but that was all I was allowed to do" — and this panel, asked
* why, talked about the refinery's green box. The rule that actually refused is printed on the
* train: trains 3/4, the Express, "may drop or pick up one freight car at every location". The
* refusal was correct. Nothing said it.
*
* That is a worse failure than a missing button, because the panel did not stay silent — it
* offered a true statement about the FACILITY, which sent the player to spend a Freight Agent
* action that could not have helped. The rule was on the train card's tooltip, which is not
* where anyone looks when a button they expected is simply absent.
*
* Only when the crew has a freight car it could otherwise set out. A budget spent by a train
* with nothing left to drop is not blocking anything, and this panel earns its keep by being
* short enough to read.
*/
const turnNow = turnOf(s, player);
if (s.clock.phase === 'localOps' && turnNow.option === 'switch') {
for (const [id, tray] of s.trays) {
if (tray.position.at !== 'grid' || tray.position.seat !== seatOf(s, player)) continue;
if (!tray.consist.some(isFreight)) continue;
if (!freightRuleSpentHere(s, player, id)) continue;
const { row, col } = tray.position.coord;
out.push({
where: `Train ${tray.trainNumber} at (${col},${row})`,
why:
'ONE FREIGHT CAR PER LOCATION — this train has already worked a freight car on this ' +
'square, so no more come off or on here until next turn. It may still work one at the ' +
'next square it reaches.',
// The printed rule doing its job, and it lifts by itself. Amber, not red.
severity: 'waiting',
});
}
}
const turn = turnOf(s, player); const turn = turnOf(s, player);
if (s.clock.phase === 'localOps' && turn.option === 'switch' && turn.movesRemaining > 0) { if (s.clock.phase === 'localOps' && turn.option === 'switch' && turn.movesRemaining > 0) {
for (const [id, tray] of s.trays) { for (const [id, tray] of s.trays) {
+26 -5
View File
@@ -48,7 +48,7 @@ import {
import type { Intent } from '../engine/intents.ts'; import type { Intent } from '../engine/intents.ts';
import type { Facility, GameConfig, GameState, PlayerIndex, SeatIndex, TrackCard, TurnoutOrientation } from '../engine/state.ts'; import type { Facility, GameConfig, GameState, PlayerIndex, SeatIndex, TrackCard, TurnoutOrientation } from '../engine/state.ts';
import { actingPlayer, carsOn, playerAtSeat, railFacingOf, seatOf, turnOf } from '../engine/state.ts'; import { actingPlayer, carsOn, playerAtSeat, railFacingOf, seatOf, turnOf } from '../engine/state.ts';
import type { Hand, HouseRules, TrackGeometry } from '../engine/content.ts'; import type { Direction, Hand, HouseRules, TrackGeometry } from '../engine/content.ts';
import type { Port } from '../engine/track.ts'; import type { Port } from '../engine/track.ts';
import { connectionsFor, slopeOfPair, variantsFor } from '../engine/track.ts'; import { connectionsFor, slopeOfPair, variantsFor } from '../engine/track.ts';
import type { Impediment } from './narrate.ts'; import type { Impediment } from './narrate.ts';
@@ -1259,10 +1259,31 @@ export function snapshot(
* one per Stage — and the entry point is what the rules actually move. There is nothing left * one per Stage — and the entry point is what the rules actually move. There is nothing left
* to reconstruct. * to reconstruct.
*/ */
// One region per Stage, straight off the card's own count: what a train has LEFT to run says /**
// where it is standing. `regionOfTransit` is the engine's own answer, so the picture and the * AND WHICH WAY IT CAME IN (Gitea#22). `regionOfTransit` counts from the end the train
// collision rule cannot disagree about who is where. * ENTERED — everything still to run is region 0 — and both directions share that one index
const place = (t: { stagesRemaining: number }): number => regionOfTransit(n.card, t.stagesRemaining); * space, which is what the collision rules want and why the engine asks it directly.
*
* The map is asking a different question: which printed box, LEFT TO RIGHT. East is right
* here and always has been, so for an eastbound train the two questions have the same answer
* by luck — it enters at the west end, so "just entered" and "leftmost box" coincide. A
* westbound train enters at the EAST end, so its region 0 is the right-hand box, and using
* the travel index directly drew the whole card mirrored.
*
* That cost a collision (seed 550943578, undo 187): a westbound TX17 that had just entered
* was drawn WEST of a westbound T5 that was nearly across, so the train physically behind
* appeared to be the one in front. Train 3 was cleared to follow T5 and ran into TX17 —
* where the rules had always had it.
*
* So the engine's index is turned into a place on the map here, once, at the boundary the
* map is drawn from. `regionOfTransit` keeps its meaning and the collision rules are
* untouched; only the picture changes.
*/
const place = (t: { stagesRemaining: number; direction: Direction }): number => {
const travelled = regionOfTransit(n.card, t.stagesRemaining);
const regions = mainlineProfile(n.card).regions;
return t.direction === 'west' ? regions - 1 - travelled : travelled;
};
return { return {
kind: 'ml', kind: 'ml',
label: name, label: name,
+74 -2
View File
@@ -1014,7 +1014,11 @@ describe("a train is made up to its card's consist (§8.2)", () => {
describe('regions on a Mainline card (§2.1, §8.2)', () => { describe('regions on a Mainline card (§2.1, §8.2)', () => {
/** Put one train mid-crossing and ask the view where the map should draw it. */ /** Put one train mid-crossing and ask the view where the map should draw it. */
const regionFor = (stagesTotal: number, stagesRemaining: number): { region: number; regions: number } => { const regionFor = (
stagesTotal: number,
stagesRemaining: number,
direction: 'east' | 'west' = 'east',
): { region: number; regions: number } => {
const s = createGame({ const s = createGame({
id: 'reg', id: 'reg',
seed: 4, seed: 4,
@@ -1027,7 +1031,7 @@ describe('regions on a Mainline card (§2.1, §8.2)', () => {
const node = s.division.nodes.find((n) => n.kind === 'mainline'); const node = s.division.nodes.find((n) => n.kind === 'mainline');
assert.ok(node && node.kind === 'mainline'); assert.ok(node && node.kind === 'mainline');
const tray = [...s.trays.keys()][0]!; const tray = [...s.trays.keys()][0]!;
node.transits.push({ tray, stagesRemaining, stagesTotal, direction: 'east' }); node.transits.push({ tray, stagesRemaining, stagesTotal, direction });
const ml = snapshot(s, [], null).division.find((n) => n.kind === 'ml'); const ml = snapshot(s, [], null).division.find((n) => n.kind === 'ml');
assert.ok(ml, 'no Mainline node in the view'); assert.ok(ml, 'no Mainline node in the view');
const t = ml!.trains.flat()[0]!; const t = ml!.trains.flat()[0]!;
@@ -1070,6 +1074,74 @@ describe('regions on a Mainline card (§2.1, §8.2)', () => {
} }
} }
}); });
it('never leaves the card it is on, whichever way it runs', () => {
for (const direction of ['east', 'west'] as const) {
for (let total = 1; total <= 4; total++) {
for (let left = total; left >= 1; left--) {
const r = regionFor(total, left, direction).region;
assert.ok(r >= 0 && r <= 1, `${direction}, total ${total}, ${left} left put the train in region ${r}`);
}
}
}
});
/**
* GITEA#22 — A WESTBOUND TRAIN WAS DRAWN IN THE WRONG HALF OF THE CARD.
*
* `regionOfTransit` answers "how far along its crossing is this train", counted from the end it
* ENTERED: a train with everything still to run is in region 0. That is the right question for the
* collision rules, which is what the engine asks it, and both directions share the one index space.
*
* The map asks a different question — WHICH PRINTED BOX, left to right — and used the same number
* for it. East is right on this map and always has been, so for an eastbound train the two agree by
* luck: it enters at the west end, so "just entered" and "leftmost box" are the same box. A
* westbound train enters at the EAST end, so its region 0 is the card's RIGHT-hand box, and drawing
* it at index 0 put it at the left — the whole card mirrored.
*
* Reported from seed 550943578, undo 187, and it cost a collision. Three westbound trains: TX17 had
* just entered (2 Stages still to run, so travel index 0) and T5 was nearly across (1 Stage left,
* index 1). Physically TX17 was BEHIND T5 — further east, the direction they had both come from.
* The map drew TX17 at the left and so put it further WEST, which reads as further ahead. Asked
* whether Train 3 could follow Train 5 onto the card, the Superintendent said yes, and Train 3
* entered behind — into TX17, exactly where the rules had it and nowhere near where the map did.
*
* The engine was right throughout. Only the picture lied, so the fix is one mirror in the view and
* the collision rules are untouched. This is the same class of bug as the consist row at the
* Whistle Post (`board-svg.ts`, seed 270861860), which came out mirrored for the same reason.
*/
describe('Gitea#22 — the map draws a westbound train where it actually is', () => {
it('mirrors a westbound train, because it entered from the east end', () => {
// Two-region card. Just entered, 2 Stages still to run: an eastbound train is in the WEST box
// and a westbound one is in the EAST box, because they came in at opposite ends.
assert.equal(regionFor(2, 2, 'east').region, 0);
assert.equal(regionFor(2, 2, 'west').region, 1);
// One Stage left, nearly across: the two swap.
assert.equal(regionFor(2, 1, 'east').region, 1);
assert.equal(regionFor(2, 1, 'west').region, 0);
});
it('puts the follower behind the leader, not in front of it — the seed 550943578 collision', () => {
// TX17 had just entered; T5 was a Stage from the far end. Both westbound, so BEHIND means to
// the east, which is to the right, which is the higher index.
const tx17 = regionFor(2, 2, 'west').region;
const t5 = regionFor(2, 1, 'west').region;
assert.ok(
tx17 > t5,
`a westbound train that has just entered must be drawn east of one that is nearly across, ` +
`but TX17 was drawn at ${tx17} and T5 at ${t5}`,
);
});
it('leaves an eastbound train where it has always been drawn', () => {
// The mirror must not disturb the direction that was right, which is every existing region test
// above — those all run east — and the case the printed rule was written for.
assert.equal(regionFor(2, 2, 'east').region, 0);
assert.equal(regionFor(2, 1, 'east').region, 1);
assert.equal(regionFor(1, 1, 'east').region, 1);
});
});
}); });
describe('Q13 — a train that catches the one ahead runs into it', () => { describe('Q13 — a train that catches the one ahead runs into it', () => {
+85 -2
View File
@@ -13,8 +13,8 @@ import { DEFAULT_MAX_COLLISIONS_PER_DAY, DEFAULT_MAX_COLLISIONS_TOTAL, collectiv
import type { GameEvent } from '../src/engine/events.ts'; import type { GameEvent } from '../src/engine/events.ts';
import { areaOf } from '../src/engine/apply.ts'; import { areaOf } from '../src/engine/apply.ts';
import { createGame } from '../src/engine/setup.ts'; import { createGame } from '../src/engine/setup.ts';
import { coordKey } from '../src/engine/state.ts'; import { coordKey, turnOf } from '../src/engine/state.ts';
import type { GameConfig, GameState } from '../src/engine/state.ts'; import type { GameConfig, GameState, GridCoord } from '../src/engine/state.ts';
import { developerBot, playGame } from '../src/sim/bot.ts'; import { developerBot, playGame } from '../src/sim/bot.ts';
import { impediments, isVisible, narrate, phaseLabel } from '../src/sim/narrate.ts'; import { impediments, isVisible, narrate, phaseLabel } from '../src/sim/narrate.ts';
import { compress, rehydrateCells, record, renderHtml } from '../src/sim/replay.ts'; import { compress, rehydrateCells, record, renderHtml } from '../src/sim/replay.ts';
@@ -138,6 +138,89 @@ describe('impediments', () => {
s.freeTrays = []; s.freeTrays = [];
assert.ok(impediments(s, 0).some((b) => /HELD/.test(b.why))); assert.ok(impediments(s, 0).some((b) => /HELD/.test(b.why)));
}); });
/** A crew standing on `at`, working the given train, with one empty tank car on the drawbar. */
const express = (s: GameState, trainNumber: number, at: GridCoord): string => {
const id = s.freeTrays.pop()!;
s.trays.set(id, {
id, trainNumber, trainIsExtra: false, engineAt: 0,
consist: [{ type: 'tank', loaded: false, origin: 0 }],
direction: 'east', position: { at: 'grid', seat: 0, coord: at }, movesUsed: 0,
});
return id;
};
/**
* GITEA#21 — THE GAME REFUSED, AND THE PANEL EXPLAINED SOMETHING ELSE.
*
* "I wanted to drop two empty tank cars so that the freight agents and men at work could load
* them later. I dropped the first tank car, but that was all I was allowed to do. Checked
* 'Blocked — why nothing is moving' and saw: refinery 1,0 — green box empty — nothing to load
* (needs a Freight Agent action)."
*
* Replayed from the attached save (seed 550943578, 181 intents): the crew was Train 3, and the
* engine's answer was `FREIGHT_WORKED_HERE`. Train 3 is the Express, and the Express prints "May
* drop or pick up one freight car at every location" — so THE REFUSAL WAS CORRECT and the rule
* is not what is wrong here. It resets next turn, and the Express may work a car at the next
* square this turn; that is what makes it an Express rather than a one-car-a-Stage train.
*
* What was wrong is that nothing said so. The panel whose entire job is "why is nothing moving?"
* listed the refinery's green box — a true statement about the FACILITY, and nothing to do with
* why the drop was refused — so the player was sent to fix a Freight Agent action that would not
* have helped. The rule was on the train card's own tooltip, which is not where somebody looks
* when a button they expected is missing.
*
* The panel is where a refusal gets explained, so the budget belongs in it.
*/
it('says when the Express has spent its one freight car on this square (Gitea#21)', () => {
const s = createGame({ id: 'g', seed: 5, config, playerNames: ['p'] });
const area = areaOf(s, 0);
const at = area.officeCoord;
// A crew standing at the Office working Train 3 — the Express — with a tank car still on it.
const trayId = express(s, 3, at);
s.clock.phase = 'localOps';
const turn = turnOf(s, 0);
turn.option = 'switch';
// Nothing to say before it has worked anything here.
assert.ok(
!impediments(s, 0).some((b) => /FREIGHT CAR PER LOCATION/i.test(b.why)),
'the budget was reported spent before the train had worked a car at all',
);
// Now it has set one car out here — exactly the state the save is in at intent 181.
turn.freightWorked[`${trayId}@${coordKey(at)}`] = 1;
const row = impediments(s, 0).find((b) => /FREIGHT CAR PER LOCATION/i.test(b.why));
assert.ok(
row,
`nothing explained the refusal:\n${JSON.stringify(impediments(s, 0), null, 2)}`,
);
// It must name the train, or a player with three crews out cannot tell which one it means.
assert.match(row!.where, /Train 3/);
// And it must say the limit lifts, or it reads as "this train can never work here again".
assert.match(row!.why, /turn/i);
// Amber: this is the printed rule doing its job, not a fault.
assert.equal(row!.severity, 'waiting');
});
it('leaves every other train alone — the rule is printed on 3 and 4 only (Gitea#21)', () => {
const s = createGame({ id: 'g', seed: 5, config, playerNames: ['p'] });
const area = areaOf(s, 0);
const at = area.officeCoord;
// Train 5 is The Sparrow, which prints no per-location freight limit.
const trayId = express(s, 5, at);
s.clock.phase = 'localOps';
turnOf(s, 0).option = 'switch';
turnOf(s, 0).freightWorked[`${trayId}@${coordKey(at)}`] = 1;
assert.ok(
!impediments(s, 0).some((b) => /FREIGHT CAR PER LOCATION/i.test(b.why)),
'a train with no such rule was told it had spent a budget it does not have',
);
});
}); });
/** /**
+39
View File
@@ -3253,6 +3253,45 @@ describe('the Division map shows the whole route', () => {
} }
} }
}); });
/**
* GITEA#22 — and the same invariant, applied to the trains standing on a card.
*
* The mirror itself is proved on the view in `mainline-cards.test.ts`. This is the other end of
* it: that the number the view hands over actually reaches the canvas as a position, so the chip
* a player looks at is on the correct half. The report was about the PICTURE, and a view that is
* right behind a renderer that ignores it would read to a player as no fix at all.
*
* Asserted on x, the way the Heavy Grade wedge above is: east is right on this map, so a
* westbound train that has just entered belongs to the RIGHT of one that is nearly across, and
* an eastbound pair in the same state belongs the other way round.
*/
it('draws a westbound train on the half of the card it is actually standing on (Gitea#22)', () => {
const card = (trains: { label: string; region: number }[]): DivisionView =>
({
kind: 'ml', label: 'Curves', capacity: 1, modifiers: [], gradeUp: null, regions: 2,
what: 'two regions',
trains: [trains.map((t) => ({ ...t, cars: [], facing: 'w' }))],
} as unknown as DivisionView);
const xOf = (svg: string, label: string): number => {
const m = new RegExp(`<text class="bs-tlab" x="([\\d.]+)"[^>]*>${label}[^<]*<`).exec(svg);
assert.ok(m, `no chip drawn for ${label}`);
return Number(m![1]);
};
// The two regions the view now reports for the seed 550943578 card: TX17 had just entered
// westbound (the east box, index 1) and T5 was nearly across (the west box, index 0).
const svg = divisionSvg([card([{ label: 'TX17', region: 1 }, { label: 'T5', region: 0 }])]);
assert.ok(
xOf(svg, 'TX17') > xOf(svg, 'T5'),
'the train that has just entered westbound was not drawn east of the one nearly across',
);
// And the halves are genuinely distinct — a renderer that centred both would satisfy nothing
// above but would still tell a player nothing.
assert.notEqual(xOf(svg, 'TX17'), xOf(svg, 'T5'), 'both chips were drawn at the same x');
});
}); });
describe('every square the menu offers can actually be clicked (regression)', () => { describe('every square the menu offers can actually be clicked (regression)', () => {