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:
co-authored by
Claude Opus 5
parent
6058f6c17e
commit
7ade60e21f
@@ -50,3 +50,9 @@ __pycache__/
|
||||
# one that is worth publishing.
|
||||
/playtests/*
|
||||
!/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/
|
||||
|
||||
@@ -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
|
||||
|
||||
Four pieces of feedback on the solitaire setup screen, plus the rules bug the second one exposed.
|
||||
|
||||
@@ -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
|
||||
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
|
||||
fix was not what the report implied, the ruling that decided it. Auto-closing leaves an issue with
|
||||
no record of which commit or which release answered it. Done for every issue closed so far: #2,
|
||||
#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
|
||||
list of the ten unbuilt cards, so they do not vanish with the issue. The token and the API calls
|
||||
are in the workspace's `AGENTS.local.md`.
|
||||
30. **Close a Gitea issue with `Closes #<n>` in the commit — not by hand** (Jesse, 2026-08-30).
|
||||
Gitea records the linking commit itself when it auto-closes, so a manual close adds nothing but a
|
||||
step, and the same rule is what the workspace `AGENTS.md` already prescribes.
|
||||
|
||||
**What auto-closing does NOT carry is a RULING, and that still has to be written by hand.** When
|
||||
the fix was not what the report implied, comment on the issue before it closes, saying what was
|
||||
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,
|
||||
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
|
||||
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;
|
||||
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
|
||||
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
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "station-master",
|
||||
"version": "0.7.9",
|
||||
"version": "0.7.9.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Station Master — a railroad operations game",
|
||||
|
||||
+16
-1
@@ -607,7 +607,8 @@ function freightWorkedKey(trayId: TrayId, at: GridCoord): string {
|
||||
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.
|
||||
@@ -657,6 +658,20 @@ function switchingRefusal(tray: CrewTray): RejectionCode | 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).
|
||||
*
|
||||
|
||||
@@ -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
|
||||
* 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 cars = t.cars ?? [];
|
||||
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')}${
|
||||
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
|
||||
// me?" gets asked, and EXPEDITED is the answer more often than not.
|
||||
t.what ? `\n\n${esc(t.what)}` : ''
|
||||
|
||||
+37
-1
@@ -18,7 +18,7 @@
|
||||
import { MAX_CONSIST } from '../engine/content.ts';
|
||||
import { adTrackCount, coordKey, seatOf, turnOf } 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';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -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
|
||||
* 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);
|
||||
if (s.clock.phase === 'localOps' && turn.option === 'switch' && turn.movesRemaining > 0) {
|
||||
for (const [id, tray] of s.trays) {
|
||||
|
||||
+26
-5
@@ -48,7 +48,7 @@ import {
|
||||
import type { Intent } from '../engine/intents.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 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 { connectionsFor, slopeOfPair, variantsFor } from '../engine/track.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
|
||||
* 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
|
||||
// collision rule cannot disagree about who is where.
|
||||
const place = (t: { stagesRemaining: number }): number => regionOfTransit(n.card, t.stagesRemaining);
|
||||
/**
|
||||
* AND WHICH WAY IT CAME IN (Gitea#22). `regionOfTransit` counts from the end the train
|
||||
* ENTERED — everything still to run is region 0 — and both directions share that one index
|
||||
* 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 {
|
||||
kind: 'ml',
|
||||
label: name,
|
||||
|
||||
@@ -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)', () => {
|
||||
/** 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({
|
||||
id: 'reg',
|
||||
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');
|
||||
assert.ok(node && node.kind === 'mainline');
|
||||
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');
|
||||
assert.ok(ml, 'no Mainline node in the view');
|
||||
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', () => {
|
||||
|
||||
+85
-2
@@ -13,8 +13,8 @@ import { DEFAULT_MAX_COLLISIONS_PER_DAY, DEFAULT_MAX_COLLISIONS_TOTAL, collectiv
|
||||
import type { GameEvent } from '../src/engine/events.ts';
|
||||
import { areaOf } from '../src/engine/apply.ts';
|
||||
import { createGame } from '../src/engine/setup.ts';
|
||||
import { coordKey } from '../src/engine/state.ts';
|
||||
import type { GameConfig, GameState } from '../src/engine/state.ts';
|
||||
import { coordKey, turnOf } from '../src/engine/state.ts';
|
||||
import type { GameConfig, GameState, GridCoord } from '../src/engine/state.ts';
|
||||
import { developerBot, playGame } from '../src/sim/bot.ts';
|
||||
import { impediments, isVisible, narrate, phaseLabel } from '../src/sim/narrate.ts';
|
||||
import { compress, rehydrateCells, record, renderHtml } from '../src/sim/replay.ts';
|
||||
@@ -138,6 +138,89 @@ describe('impediments', () => {
|
||||
s.freeTrays = [];
|
||||
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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -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)', () => {
|
||||
|
||||
Reference in New Issue
Block a user