v0.3.0 — playtest fixes, two new rules, and §7 enforced

This commit is contained in:
Jesse
2026-08-12 21:08:28 -04:00
parent 6ead39c530
commit 1bf1e95058
38 changed files with 8514 additions and 5989 deletions
+149 -1
View File
@@ -14,6 +14,7 @@ import assert from 'node:assert/strict';
import { advance } from '../src/engine/advance.ts';
import { applyIntent, areaOf, check } from '../src/engine/apply.ts';
import { legalActions } from '../src/engine/legal.ts';
import {
MAINLINE_MODIFIER_CARDS,
MAINLINE_PROFILES,
@@ -488,7 +489,7 @@ describe('the Limits sign moves with the Running Track (§2.1, Gap 4a)', () => {
);
// The district hangs off that turnout and is not bounded by the Limits at all. Variant 1 of a
// left-hand curve is the `ne` arc — the one on the same diagonal as the turnout's leg.
// left-hand curve is the `nw` arc — the one on the same diagonal as the turnout's leg.
assert.equal(
check(s, 0, {
type: 'card.play', cardId: trackInHand(s, 'curved', 'left'),
@@ -529,6 +530,153 @@ describe('the Limits sign moves with the Running Track (§2.1, Gap 4a)', () => {
});
});
// ---------------------------------------------------------------------------
describe('a turnout may be laid on top of a card already down', () => {
/** Lays a straight inside the Limits and returns where it went. */
const layStraight = (s: GameState): GridCoord => {
const area = areaOf(s, 0);
const target = { row: area.runningRow, col: area.limitsEast.col };
const r = applyIntent(s, 0, {
type: 'card.play', cardId: trackInHand(s, 'straight', 'none'), placement: target, variant: 0,
});
assert.ok(r.ok, 'the straight should lay on the Limits sign');
return target;
};
it('upgrades a straight, which is how a district opens off track already built', () => {
// REPORTED from playtesting: a district can only hang off a turnout, so a player who laid a
// straight along the main and then wanted to branch there had no move at all — the piece had to
// have been a turnout when it went down.
const s = game();
s.turn.option = 'draw';
const at = layStraight(s);
for (const hand of ['left', 'right'] as const) {
for (let variant = 0; variant < 2; variant++) {
assert.equal(
check(s, 0, { type: 'card.play', cardId: trackInHand(s, 'turnout', hand), placement: at, variant }),
null,
`a ${hand}-hand turnout at rotation ${variant} must be able to upgrade a straight`,
);
}
}
const r = applyIntent(s, 0, {
type: 'card.play', cardId: trackInHand(s, 'turnout', 'right'), placement: at, variant: 0,
});
assert.ok(r.ok);
const now = areaOf(s, 0).grid.get(`${at.row},${at.col}`)!;
assert.equal(now.geometry.kind === 'track' && now.geometry.geometry, 'turnout', 'the square is still a straight');
});
it('upgrades a curve only when the turnout diverges onto the same arc', () => {
// The ARC, not merely the diagonal. A `sw` curve and an `ne` one share a slope but leave by
// opposite edges, so swapping one for the other would move the leg off whatever it joined.
const s = game();
const area = areaOf(s, 0);
s.turn.option = 'draw';
// A turnout on the main, then its matching curve on the row below — the start of every siding.
const turnoutAt = { row: area.runningRow, col: area.limitsEast.col };
assert.ok(applyIntent(s, 0, {
type: 'card.play', cardId: trackInHand(s, 'turnout', 'right'), placement: turnoutAt, variant: 0,
}).ok, 'the turnout should lay on the Limits sign');
const curveAt = { row: area.runningRow - 1, col: turnoutAt.col };
assert.ok(applyIntent(s, 0, {
type: 'card.play', cardId: trackInHand(s, 'curved', 'right'), placement: curveAt, variant: 1,
}).ok, 'the matching curve should hang under the turnout');
const laid = area.grid.get(`${curveAt.row},${curveAt.col}`)!;
const arc = laid.geometry.kind === 'track' ? laid.geometry.arc : undefined;
assert.equal(arc, 'ne', 'the fixture is not the curve this test means to upgrade');
// A turnout whose leg is that same `ne` arc may replace it; every other rotation may not.
const matching: string[] = [];
for (const hand of ['left', 'right'] as const) {
for (let variant = 0; variant < 2; variant++) {
const code = check(s, 0, {
type: 'card.play', cardId: trackInHand(s, 'turnout', hand), placement: curveAt, variant,
});
if (code === null) matching.push(`${hand}/${variant}`);
else assert.equal(code, 'NOT_UPGRADEABLE_TRACK', `${hand}/${variant} should be refused as a mismatch`);
}
}
assert.deepEqual(matching, ['right/1'], 'exactly the turnout diverging onto `ne` should be accepted');
});
it('refuses to swap the track out from under a car, or out from under an Interlocking', () => {
const s = game();
const area = areaOf(s, 0);
s.turn.option = 'draw';
const at = layStraight(s);
const square = area.grid.get(`${at.row},${at.col}`)!;
square.standing = [{ type: 'boxcar', loaded: false }];
assert.equal(
check(s, 0, { type: 'card.play', cardId: trackInHand(s, 'turnout', 'right'), placement: at, variant: 0 }),
'UPGRADE_OCCUPIED',
'track was swapped out from under a standing car',
);
square.standing = [];
square.enhancements = ['interlocking'];
assert.equal(
check(s, 0, { type: 'card.play', cardId: trackInHand(s, 'turnout', 'right'), placement: at, variant: 0 }),
'UPGRADE_ENHANCED',
'the upgrade lifted a built Interlocking with the card',
);
});
it('refuses to upgrade anything that is not a straight or a matching curve', () => {
// The Office is east-west track like a straight, but it is not track to build over.
const s = game();
const area = areaOf(s, 0);
s.turn.option = 'draw';
assert.equal(
check(s, 0, {
type: 'card.play', cardId: trackInHand(s, 'turnout', 'right'),
placement: area.officeCoord, variant: 0,
}),
'NOT_UPGRADEABLE_TRACK',
'a turnout was laid over the Office',
);
});
it('does not let a straight or a curve upgrade anything — only a turnout may', () => {
const s = game();
s.turn.option = 'draw';
const at = layStraight(s);
assert.equal(
check(s, 0, { type: 'card.play', cardId: trackInHand(s, 'straight', 'none'), placement: at, variant: 0 }),
'NOT_UPGRADEABLE_TRACK',
'a straight was laid on top of a straight',
);
assert.equal(
check(s, 0, { type: 'card.play', cardId: trackInHand(s, 'curved', 'right'), placement: at, variant: 0 }),
'NOT_UPGRADEABLE_TRACK',
'a curve was laid on top of a straight',
);
});
it('offers the upgrade in the legal-move list, or it may as well not exist', () => {
// The 18 Enhancement cards were permanently dead for weeks for exactly this reason: the engine
// allowed the play and `legalIntents` never once offered a square it could go on.
const s = game();
s.turn.option = 'draw';
const at = layStraight(s);
const cardId = trackInHand(s, 'turnout', 'right');
const offered = legalActions(s, 0).some(
(i) => i.type === 'card.play' && i.cardId === cardId &&
i.placement?.row === at.row && i.placement?.col === at.col,
);
assert.ok(offered, 'the occupied square is never offered, so the upgrade cannot be played');
});
});
describe("a train is made up to its card's consist (§8.2)", () => {
it('takes a caboose when the card calls for one, and refuses a fourth freight car', () => {
// Train 9 "Heavy Freight" is freight 3 + caboose 1. It was being made up with FOUR hoppers and