v0.1.0 — undo, the crew's reach on the board, workers on the card, and four engine bugs

Fifteen items from two playtest sessions. Three that read as drawing faults were engine
bugs: cars could be added to a train that was not being made up (50 offers in 8 games),
the make-up panel merged two trains and could couple a car to the wrong one, and an
Office upgrade silently deleted what a Modifier had added. A fourth was a sentinel
inside a coordinate's own value range — a Mainline placement travelling as row -1, which
is an ordinary district row.

Trains are now drawn the way they stand: west on the left, nose toward the way the engine
faces, on both the district card and the Division chip. Undo steps back through the game
by replaying the save without its last intent. The switching walk keeps its rejections, so
the board can say why a square is not offered. Laborers and Porters are on the card, and
the rule that a district only grows outwards is finally written down.

Versions start here: third digit for fixes, second for a feature set, 1.0 for a release.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GgtkX8JnvKa8y2tuJ8aQf4
This commit is contained in:
Jesse
2026-08-08 05:23:17 -04:00
co-authored by Claude Opus 5
parent f4c0f49604
commit 5d825b97d2
20 changed files with 1604 additions and 191 deletions
+107
View File
@@ -22,6 +22,7 @@ import type { MoveContext, Occupancy, Port } from '../src/engine/track.ts';
import {
allReachable,
canDropCarsAt,
exploreMoves,
canPlaceAt,
carriesThroughTrack,
exitsFrom,
@@ -944,3 +945,109 @@ describe('backing up does not turn the train around', () => {
);
});
});
// ---------------------------------------------------------------------------
describe('why a square is NOT offered (the same walk, keeping its rejections)', () => {
/**
* REPORTED: "during switching, it seems like trains are blocked from moving onto industry in
* certain conditions. Can you make it clear what those conditions are?" They were nowhere on
* screen — a square that is not a destination simply had no button.
*
* `exploreMoves` returns the reasons out of the SAME traversal that produces the destinations, so
* a reason shown to a player is the rule that actually refused the square. A second function
* working out "why is this missing" would be a second implementation of movement, and its failure
* mode is a confident explanation that does not match the refusal.
*/
const cars = (n: number): RollingStock[] =>
Array.from({ length: n }, () => ({ type: 'boxcar', loaded: false }) as RollingStock);
const workingIndustry = (): TrackCard => ({
geometry: { kind: 'facility', facility: 'mineTipple' },
baseOperationalRail: true,
standing: [],
facility: {
kind: 'freight', subtype: 'mineTipple',
allows: { outbound: true, inbound: false },
outboundBox: [], inboundBox: [], capacity: { outbound: 1, inbound: 0 },
// A load on MEN locks the track (§9.3).
menAtWork: [{ type: 'hopper', dir: 'out' }, null, null] as never,
industryTrack: { length: 2, cars: [] },
laborers: 1, porters: 0, usedThisStage: { laborers: 0, porters: 0 },
},
modifiers: [],
enhancements: [],
});
const reasonFor = (area: OfficeArea, over: Partial<MoveContext>, r: number, c: number): string | null => {
const { blocked } = exploreMoves(ctxFor(area, over), at(0, 0), 'e');
return blocked.find((b) => b.coord.row === r && b.coord.col === c)?.why ?? null;
};
it('says MEN AT WORK when an industry is locked, and refuses the square', () => {
const area = areaFrom(
{ [coordKey(at(0, 0))]: straight(), [coordKey(at(0, 1))]: workingIndustry() },
at(0, 5),
);
const { destinations, blocked } = exploreMoves(ctxFor(area), at(0, 0), 'e');
assert.ok(!has(destinations, 0, 1), 'a locked industry must not be a destination');
const why = blocked.find((b) => b.coord.col === 1);
assert.equal(why?.kind, 'locked');
assert.match(why!.why, /MEN AT WORK/);
});
it('says which card is occupied, and by what rule', () => {
const area = areaFrom(
{ [coordKey(at(0, 0))]: straight(), [coordKey(at(0, 1))]: straight() },
at(0, 5),
);
const occupancy: Occupancy = { trayAt: (c) => (c.col === 1 ? 'tray9' : null), freeAdTracks: () => 0 };
assert.match(reasonFor(area, { occupancy }, 0, 1) ?? '', /another train is standing here/);
});
it('says when a move would overfill the train, and counts the cars it would couple', () => {
const area = areaFrom(
{ [coordKey(at(0, 0))]: straight(), [coordKey(at(0, 1))]: straight(cars(3)) },
at(0, 5),
);
const why = reasonFor(area, { consistSize: 3 }, 0, 1) ?? '';
assert.match(why, /too many cars/);
assert.match(why, /couples 3 standing car/, 'the count must be the cars actually there');
});
it('tells a pass-through apart from an obstruction', () => {
// A turnout is not in the way: a train runs through one all day and simply may not finish a
// Move on it. Reporting that as "blocked" would bury the reasons that ARE obstructions.
const area = areaFrom(
{
[coordKey(at(0, 0))]: straight(),
[coordKey(at(0, 1))]: turnout({ stem: 'w', through: 'e', diverge: 's' }),
[coordKey(at(0, 2))]: straight(),
},
at(0, 5),
);
const { destinations, blocked } = exploreMoves(ctxFor(area), at(0, 0), 'e');
assert.ok(has(destinations, 0, 2), 'the train must still be able to run THROUGH the turnout');
assert.equal(blocked.find((b) => b.coord.col === 1)?.kind, 'noStopping');
});
it('never explains away a square that is actually reachable', () => {
// The walk can meet a card from a bad angle first and a good one later. A reason attached to a
// square the player CAN reach is worse than none: it argues with the button beside it.
const area = areaFrom(
{
[coordKey(at(0, 0))]: straight(),
[coordKey(at(0, 1))]: straight(),
[coordKey(at(0, 2))]: straight(),
},
at(0, 5),
);
const { destinations, blocked } = exploreMoves(ctxFor(area), at(0, 0), 'e');
for (const b of blocked) {
assert.ok(
!has(destinations, b.coord.row, b.coord.col),
`(${b.coord.row},${b.coord.col}) is both reachable and explained away`,
);
}
});
});