v0.4.8 — the Limits bound the whole district, the nine spots really are nine, and an action points at its square

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YQAJ4dND7enLj54eLyiF2C
This commit is contained in:
Jesse
2026-08-19 13:38:06 -04:00
co-authored by Claude Opus 5
parent 9f3b92d08e
commit 461c4d9bac
25 changed files with 3264 additions and 4524 deletions
+179 -1
View File
@@ -930,6 +930,177 @@ describe('a Modifier only goes beside a host that can use it (regression)', () =
// ---------------------------------------------------------------------------
describe('the Limits bound the district, and the nine spots reach round a Facility', () => {
/**
* A district whose Running Track has been extended one square east, so the sign stands at col 2
* and there is a siding hanging below the main to build on:
*
* row 0: [lim] [office] [turnout, leg south] [lim] <- Running Track, Limits at -1 and 2
* row -1: [curve ne]
*
* Returns the industry-ready square under the sign, at (-1, 2).
*/
function district(s: GameState): GridCoord {
const area = areaOf(s, 0);
const plain = (geometry: object): TrackCard => ({
geometry: geometry as TrackCard['geometry'],
baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [],
});
const col = area.limitsEast.col;
area.grid.set(coordKey(at(area.runningRow, col)), plain({
kind: 'track', geometry: 'turnout', turnout: { stem: 'w', through: 'e', diverge: 's' }, hand: 'left',
}));
area.grid.set(coordKey(at(area.runningRow - 1, col)), plain({
kind: 'track', geometry: 'curved', arc: 'ne', hand: 'left',
}));
area.limitsEast = at(area.runningRow, col + 1);
area.grid.set(coordKey(area.limitsEast), plain({ kind: 'limits' }));
s.clock.phase = 'localOps';
s.clock.currentActor = 0;
turnOf(s, 0).option = 'draw';
return at(area.runningRow - 1, col + 1);
}
const trackInHand = (s: GameState, geometry: string): string => {
for (const [id, card] of s.cards) {
if (card.kind.kind === 'track' && card.kind.geometry === geometry) {
s.decks.hands.set(0, [id]);
return id;
}
}
throw new Error(`no ${geometry} card`);
};
const industryOf = (s: GameState, facility: string): string => {
for (const [id, card] of s.cards) {
if (card.kind.kind === 'freightFacility' && card.kind.facility === facility) {
s.decks.hands.set(0, [id]);
return id;
}
}
throw new Error(`no industry card: ${facility}`);
};
const modifierOf = (s: GameState, modifier: string): string => {
for (const [id, card] of s.cards) {
if (card.kind.kind === 'modifier' && card.kind.modifier === modifier) {
s.decks.hands.set(0, [id]);
return id;
}
}
throw new Error(`no modifier card: ${modifier}`);
};
/** An industry standing on the board, so a Modifier has a host to hang off. */
const buildFacility = (s: GameState, kind: string, coord: GridCoord): void => {
areaOf(s, 0).grid.set(coordKey(coord), {
geometry: { kind: 'facility', facility: kind, axis: 'ew' },
baseOperationalRail: true, standing: [], modifiers: [], enhancements: [],
facility: {
kind: 'freight', subtype: kind,
allows: { outbound: true, inbound: false },
outboundBox: [], inboundBox: [], capacity: { outbound: 1, inbound: 0 },
menAtWork: [null, null, null],
industryTrack: { cars: [] },
laborers: 1, porters: 0, usedThisStage: { laborers: 0, porters: 0 },
},
} as never);
};
it('accepts a siding in the sign\'s own column and refuses one past it', () => {
/**
* REPORTED by Jesse: "sidings should not be allowed to be built outside the limits". §3 defines
* Secondary Track as "all tracks IN YOUR LIMITS that are not the Running Track", so the bound
* belongs to the district rather than to the one row the sign stands in — it used to guard the
* Running Track alone, and a siding could run east past a player's own sign, taking industries
* with it, onto track that §8.1 and §10 do not consider his territory at all.
*
* INCLUSIVE of the sign's column, which is the half that keeps the game playable: the opening
* district is signs at ±1 around the Office, so the strict reading would leave one buildable
* column and break §11.3's promise that both Secondary rows are usable from the first Stage.
*/
const s = game();
const under = district(s);
const cardId = trackInHand(s, 'straight');
assert.equal(
check(s, 0, { type: 'card.play', cardId, placement: under }),
null,
'a siding may run under the Limits sign — the sign stands ON the boundary, not beyond it',
);
assert.equal(
check(s, 0, { type: 'card.play', cardId, placement: at(under.row, under.col + 1) }),
'OUTSIDE_LIMITS',
'a siding was built outside the district it belongs to',
);
});
it('refuses an industry outside the Limits — a Facility carries track', () => {
// §11.2: "Facility cards carry their own rails — placing a Facility places track." So the bound
// is the same one, and said with the same code rather than left to read as NOT_CONNECTED.
const s = game();
const under = district(s);
const cardId = industryOf(s, 'mineTipple');
assert.equal(check(s, 0, { type: 'card.play', cardId, placement: under }), null);
assert.equal(
check(s, 0, { type: 'card.play', cardId, placement: at(under.row, under.col + 1) }),
'OUTSIDE_LIMITS',
);
});
it('offers a Modifier the DIAGONAL spots around its host, not just the four orthogonal ones', () => {
/**
* REPORTED by Jesse: a Modifier could not be placed to the south-east of his industry. §9 places
* one "adjacent to a Facility, on any of the nine nearby spots" and `check` has always accepted
* all eight neighbours — it was `placementCandidates` that walked north, south, east and west
* only, so a diagonal square with no orthogonal neighbour was legal and never offered.
*/
const s = game();
const under = district(s);
buildFacility(s, 'packingSheds', under);
const cardId = modifierOf(s, 'iceHouse');
const offered = legalActions(s, 0)
.filter((i) => i.type === 'card.play' && i.cardId === cardId && i.placement !== undefined)
.map((i) => coordKey((i as { placement: GridCoord }).placement));
// South-east of the host, and orthogonally adjacent to nothing at all.
const southEast = at(under.row - 1, under.col + 1);
assert.ok(
offered.includes(coordKey(southEast)),
`the south-east spot (${southEast.row}, ${southEast.col}) is legal but was never offered — offered: ${offered.join(' ')}`,
);
assert.equal(check(s, 0, { type: 'card.play', cardId, placement: southEast }), null);
});
it('lets a Modifier hang outside the Limits, but never in the Running Track row', () => {
/**
* Jesse's call, both halves. A Modifier is not track (§9), so a host standing at the limit keeps
* all nine of its spots — refusing the outer three would make the card unplayable exactly where
* the district ends. The Running Track ROW is the exception: inside the Limits that row is
* always full, so this bites only beyond the sign, and that is the ground the main grows onto —
* a Modifier parked there would block the player's own sign from moving outward (§2.1).
*/
const s = game();
const under = district(s);
buildFacility(s, 'packingSheds', under);
const cardId = modifierOf(s, 'iceHouse');
const area = areaOf(s, 0);
assert.equal(
check(s, 0, { type: 'card.play', cardId, placement: at(under.row, under.col + 1) }),
null,
'a Modifier beside a host at the limit was refused the spot outside it',
);
assert.equal(
check(s, 0, { type: 'card.play', cardId, placement: at(area.runningRow, under.col + 1) }),
'ON_RUNNING_TRACK',
'a Modifier was allowed to stand in the row the Running Track grows along',
);
});
});
// ---------------------------------------------------------------------------
describe("a Modifier grants only what its host's flow can use", () => {
const withFacility = (kind: string, out: boolean, into: boolean) => ({
geometry: { kind: 'facility', facility: kind, axis: 'ew' },
@@ -1111,8 +1282,13 @@ describe('Industry cards go on a stub, and lock each other out', () => {
* A district with a siding hanging off the Running Track, which is the only place an industry may
* go. Returns the siding square east of the curve.
*
* row 0: [lim] [office] [turnout, leg south] … <- Running Track
* row 0: [lim] [office] [turnout, leg south] [lim] <- Running Track
* row -1: [curve ne] [siding square]
*
* The turnout is laid ON the east Limits sign, which is how the Running Track grows — so the sign
* MOVES OUT with it (§2.1, Gap 4a), exactly as `extendLimitsIfNeeded` does when the card is played
* rather than written straight into the grid. Without that the siding square would be outside the
* district's own Limits, which is no longer a place track may go.
*/
function withSiding(s: GameState): GridCoord {
const area = areaOf(s, 0);
@@ -1127,6 +1303,8 @@ describe('Industry cards go on a stub, and lock each other out', () => {
area.grid.set(coordKey(at(area.runningRow - 1, col)), plain({
kind: 'track', geometry: 'curved', arc: 'ne', hand: 'left',
}));
area.limitsEast = at(area.runningRow, col + 1);
area.grid.set(coordKey(area.limitsEast), plain({ kind: 'limits' }));
s.clock.phase = 'localOps';
s.clock.currentActor = 0;
turnOf(s, 0).option = 'draw';