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:
+179
-1
@@ -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';
|
||||
|
||||
+86
-2
@@ -598,6 +598,54 @@ describe('the board shows freight work happening', () => {
|
||||
assert.ok(y > 48, `the caption at y=${y} prints over the rail`);
|
||||
});
|
||||
|
||||
it('draws the Limits as the edge of the buildable district, at every row', () => {
|
||||
/**
|
||||
* Track may not be laid outside the Limits at ANY row now (§2.1), and two signs sitting on the
|
||||
* Running Track could not say that: a player looking at open ground beyond a sign had no way to
|
||||
* know nothing of his would ever go there until the square failed to light up.
|
||||
*
|
||||
* Just OUTSIDE the sign's own column, because the sign stands ON the boundary — a siding may run
|
||||
* under it — so a line drawn inside the sign would teach the opposite of the rule.
|
||||
*/
|
||||
const game = newGame(555);
|
||||
const area = game.state.officeAreas.get(0)!;
|
||||
const f = view(game);
|
||||
assert.deepEqual(
|
||||
f.limits,
|
||||
{ west: area.limitsWest.col, east: area.limitsEast.col },
|
||||
'the Frame does not carry the district edges a remote client cannot look up',
|
||||
);
|
||||
|
||||
const svg = officeSvg(f.cells, f.runningRow, [], [], f.limits);
|
||||
const xs = [...svg.matchAll(/class="bs-limitline" x1="(-?[\d.]+)"/g)].map((m) => Number(m[1]));
|
||||
assert.equal(xs.length, 2, 'the district was drawn without both of its edges');
|
||||
|
||||
// The sign cards themselves must fall INSIDE the two lines.
|
||||
const signX = [...svg.matchAll(/data-cell="(-?\d+),(-?\d+)"[^>]*transform="translate\((-?[\d.]+)/g)]
|
||||
.filter((m) => Number(m[2]) === area.limitsWest.col || Number(m[2]) === area.limitsEast.col)
|
||||
.map((m) => Number(m[3]));
|
||||
assert.ok(signX.length > 0, 'no Limits sign was drawn to check the boundary against');
|
||||
for (const x of signX) {
|
||||
assert.ok(
|
||||
x >= Math.min(...xs) && x <= Math.max(...xs),
|
||||
`a Limits sign at x=${x} sits outside its own boundary (${xs.join(', ')})`,
|
||||
);
|
||||
}
|
||||
assert.match(svg, /bs-limitlab/, 'the boundary is drawn but never named');
|
||||
|
||||
// AND ON THE CANVAS. The west sign is the leftmost card, so a line drawn outside its column sits
|
||||
// at a negative x — off a viewBox that starts at 0, which is a boundary nobody can see.
|
||||
const vb = /viewBox="(-?[\d.]+) (-?[\d.]+) ([\d.]+) ([\d.]+)"/.exec(svg);
|
||||
assert.ok(vb, 'the board produced no viewBox');
|
||||
const x0 = Number(vb![1]);
|
||||
for (const x of xs) {
|
||||
assert.ok(
|
||||
x >= x0 && x <= x0 + Number(vb![3]),
|
||||
`the boundary at x=${x} is drawn outside the canvas (${x0} to ${x0 + Number(vb![3])})`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('says on the card which way an industry runs, and how many workers it has', () => {
|
||||
/**
|
||||
* TWO REPORTS, ONE CARD.
|
||||
@@ -1314,7 +1362,7 @@ describe('the static build', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('renders clickable highlights on the board when a card is picked', async () => {
|
||||
it('renders clickable highlights on the board, and points at the square an action names', async () => {
|
||||
// The real check: load the EMITTED bundle with a DOM stub, click a card, and confirm the board
|
||||
// came back with highlighted squares wired to handlers. Asserting the data has coordinates says
|
||||
// nothing about whether the page draws them.
|
||||
@@ -1366,7 +1414,12 @@ describe('the static build', () => {
|
||||
const classes = new Set<string>();
|
||||
found.set(key, {
|
||||
onclick: null,
|
||||
classList: { add: (c: string) => void classes.add(c), has: (c: string) => classes.has(c) },
|
||||
classList: {
|
||||
add: (c: string) => void classes.add(c),
|
||||
// Pointing at a square is a HOVER: it has to come off again when the cursor leaves.
|
||||
remove: (c: string) => void classes.delete(c),
|
||||
has: (c: string) => classes.has(c),
|
||||
},
|
||||
classes,
|
||||
});
|
||||
}
|
||||
@@ -1493,6 +1546,37 @@ describe('the static build', () => {
|
||||
const lit = (grid['highlighted'] as () => unknown[])();
|
||||
const ghosts = /data-ghost="/.test(html);
|
||||
assert.ok(lit.length > 0 || ghosts, 'picking a card highlighted nothing on the board');
|
||||
|
||||
/**
|
||||
* POINTING AT THE SQUARE A BUTTON MEANS — wired on the emitted bundle, not asserted off the menu.
|
||||
*
|
||||
* The menu carrying a coordinate proves nothing about whether the page draws it, which is the
|
||||
* same reason the highlight check above loads the real bundle. Reported by Jesse: the action list
|
||||
* is a column of near-identical sentences separated by "(1,3)" against "(-1,3)", and picking the
|
||||
* wrong one is a click to undo in solitaire and unrecoverable in a multiplayer game.
|
||||
*/
|
||||
const withSquare = (
|
||||
(actions['querySelectorAll'] as (s: string) => Record<string, unknown>[])
|
||||
.call(actions, 'button.act')
|
||||
.filter((n) => (n['dataset'] as Record<string, string>)['square'])
|
||||
);
|
||||
assert.ok(withSquare.length > 0, 'no action button carries the square it acts on');
|
||||
const btn = withSquare[0]!;
|
||||
const key = (btn['dataset'] as Record<string, string>)['square']!;
|
||||
assert.match(key, /^-?\d+,-?\d+$/, `"${key}" is not a grid coordinate`);
|
||||
(btn['onmouseenter'] as (() => void) | null)?.();
|
||||
const pointed = (grid['highlighted'] as () => Record<string, unknown>[])().filter((g) =>
|
||||
(g['classes'] as Set<string>).has('bs-point'),
|
||||
);
|
||||
assert.equal(pointed.length, 1, `hovering the action for (${key}) marked ${pointed.length} squares`);
|
||||
(btn['onmouseleave'] as (() => void) | null)?.();
|
||||
assert.equal(
|
||||
(grid['highlighted'] as () => Record<string, unknown>[])().filter((g) =>
|
||||
(g['classes'] as Set<string>).has('bs-point'),
|
||||
).length,
|
||||
0,
|
||||
'the square stayed lit after the cursor left the button',
|
||||
);
|
||||
});
|
||||
|
||||
it('busts the cache on every module, so a deploy cannot half-load', () => {
|
||||
|
||||
Reference in New Issue
Block a user