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
+185 -14
View File
@@ -14,6 +14,7 @@ import { dirname, join, resolve } from 'node:path';
import { cardDescription, cardName, describeIntent, variantLabel } from '../src/sim/view.ts';
import { variantsFor } from '../src/engine/track.ts';
import { divisionSvg, officeSvg } from '../src/sim/board-svg.ts';
import { ENHANCEMENT_RULES } from '../src/engine/content.ts';
import { facilitiesHtml, timetableHtml } from '../src/web/panels.ts';
import { record, renderHtml } from '../src/sim/replay.ts';
import { snapshot } from '../src/sim/view.ts';
@@ -264,7 +265,7 @@ describe('the action menu presents choices the way they are made', () => {
// describe it. Taken from the engine's connections, so a preview cannot promise a shape the
// placement will not produce.
const shapes = spots.map((sp) => [...sp.links].sort().join('|')).sort();
assert.deepEqual(shapes, ['es|ew', 'we|wn'], `the two rotations do not carry distinct shapes: ${shapes.join(' / ')}`);
assert.deepEqual(shapes, ['en|ew', 'we|ws'], `the two rotations do not carry distinct shapes: ${shapes.join(' / ')}`);
});
it('says how deep a Department pile is, so a discard can be aimed', () => {
@@ -395,6 +396,157 @@ describe('the board shows freight work happening', () => {
assert.deepEqual(order(-1, 1), ['green', 'maw', 'maw', 'maw'], 'the green box is not beside MEN');
});
it('says what an enhancement does, and admits when it does nothing yet', () => {
// REPORTED from playtesting: an Interlocking on the board is a bare label with no hover text.
// Writing only the printed effect would be worse than silence for the four that are read by
// nothing — a player who builds one to hold a train at the Limit watches it not happen with no
// way to tell a misread card from a bug.
const game = newGame(5);
const area = game.state.officeAreas.get(0)!;
const office = area.grid.get(`${area.officeCoord.row},${area.officeCoord.col}`)!;
// Interlocking and Telegraph both resolve in play; Overpass is the one card nothing reads.
office.enhancements = ['interlocking', 'telegraph', 'overpass'];
const cell = view(game).cells.find((c) => c.row === area.officeCoord.row && c.col === area.officeCoord.col)!;
const svg = officeSvg([cell], area.runningRow);
const tip = /class="bs-enh"[^>]*data-tip="([^"]*)"/.exec(svg)?.[1] ?? '';
assert.match(tip, /Limit Track/, 'the Interlocking does not say what it is meant to do');
assert.match(tip, /add \+?4/i, 'the Telegraph does not say what it does');
assert.match(tip, /Railroad Crossing/, 'the Overpass does not say what it is meant to do');
// Exactly ONE of the three carries the warning, and it is the Overpass. The first version of
// this test asserted the opposite for Interlocking, which is read at advance.ts:770 — that is
// how four working cards came to be labelled unimplemented on the board.
const warned = tip.split('·').filter((part) => /NOT YET IMPLEMENTED/.test(part));
assert.equal(warned.length, 1, `expected one unimplemented card, got: ${tip}`);
assert.match(warned[0]!, /Overpass/, 'the warning is on the wrong card');
});
it('marks only the enhancements that really are unwired', () => {
/**
* The status is data, so it can drift from reality — and it already did. The first version of
* this test asserted `effect === 'live'` if and only if the rule carried a `dispatchBonus`,
* which was not a check at all: it restated the very assumption that produced the table, so it
* passed while four working cards were labelled unimplemented in the UI.
*
* So this asserts the SET, keyed to what actually reads each one. A card whose behaviour gets
* written must be moved here, and the failure message says where to look.
*/
const by = (e: string): string[] =>
ENHANCEMENT_RULES.filter((r) => r.effect === e).map((r) => r.key).sort();
assert.deepEqual(
by('live'),
['absSignals', 'interlocking', 'radio', 'smallYard', 'telegraph', 'telephone', 'yardOffice'],
'grep the key itself before changing this — interlocking, yardOffice and smallYard are read ' +
'by key in advance.ts/apply.ts, and absSignals through node.absSignals, not via a helper',
);
// Wired and read, but the card that would trigger them is opponent-directed and cut from the
// solitaire deck (Q6): Derail for Facing Point Locks, Watertower for the Water Column.
assert.deepEqual(by('dormantSolo'), ['facingPointLocks', 'waterColumn']);
// Overpass alone has no code path anywhere — it would do nothing even in a multiplayer game.
assert.deepEqual(by('unbuilt'), ['overpass']);
});
it('puts the card just drawn at the FRONT of the hand, and badges it', () => {
// REPORTED from playtesting. The engine pushes a drawn card onto the END of the hand, and with
// the row wrapping that put the card you just turned over wherever the eye is least likely to be
// — among two others that look exactly like it.
const game = newGame(21);
const draw = actionGroups(game).options.find((o) => o.type === 'localOps.choose' && o.option === 'draw');
assert.ok(draw, 'this seed does not offer the draw option');
submit(game, draw!);
const before = [...(game.state.decks.hands.get(0) ?? [])];
const take = actionGroups(game).options.find((o) => o.type === 'draw.fromHomeOffice');
assert.ok(take, 'no draw is on offer');
submit(game, take!);
const after = game.state.decks.hands.get(0) ?? [];
const drawn = after.find((id) => !before.includes(id));
assert.ok(drawn, 'nothing was actually drawn');
// The engine still pushes — deliberately, so the bot's hand iteration and every revenue figure
// measured with it are untouched. The reversal is the display's, in both places that show a hand.
assert.equal(after[after.length - 1], drawn, 'the engine should still append; only the views reverse');
assert.equal(actionMenu(game).hand[0]?.cardId, drawn, 'the new card is not first on the play page');
assert.equal(view(game).hand[0], cardName(game.state, drawn!), 'the new card is not first in the replay frame');
assert.equal(game.justDrawn, drawn, 'the badge does not know which card is new');
// The names and their descriptions must reverse together, or the tooltips come off the wrong card.
const f = view(game);
assert.deepEqual(
f.handWhat,
[...after].reverse().map((id) => cardDescription(game.state, id)),
'hand and handWhat are out of step',
);
});
it('draws no freight fittings on a Depot, a Station or a Terminal', () => {
// REPORTED from playtesting: a Depot showed three MEN | AT | WORK boxes. An Office is a Passenger
// Facility — the sign is printed "For Freight Facilities" (§9.1) and every tier is built with 0
// Laborers, so the boxes could never be worked. They were drawn because `menAtWork` was a
// three-slot array of nulls on every facility and this loop had no guard, unlike the green and
// red rows either side of it. The engine now has no pipeline to draw on a passenger facility.
const game = newGame(5);
const area = game.state.officeAreas.get(0)!;
// Depot 1/1/1, Station 2/2/2, Terminal 3/3/3 (OFFICE_PROFILES). All three carry 0 Laborers and
// — the point of this test — no MEN | AT | WORK pipeline at all.
for (const [tier, n] of [['depot', 1], ['station', 2], ['terminal', 3]] as const) {
area.tier = tier;
const office = area.grid.get(`${area.officeCoord.row},${area.officeCoord.col}`)!;
office.facility = {
kind: 'passenger', subtype: 'office',
allows: { outbound: true, inbound: true },
outboundBox: [{ type: 'coach', loaded: true }], inboundBox: [{ type: 'coach', loaded: true }],
capacity: { outbound: n, inbound: n },
menAtWork: null,
industryTrack: { length: 0, cars: [] },
laborers: 0, porters: n, usedThisStage: { laborers: 0, porters: 0 },
};
const cell = view(game).cells.find((c) => c.row === area.officeCoord.row && c.col === area.officeCoord.col)!;
const svg = officeSvg([cell], area.runningRow);
const boxes = [...svg.matchAll(/class="bs-wb (bs-\w+)/g)].map((m) => m[1]!.replace('bs-', ''));
assert.ok(!boxes.includes('maw'), `a ${tier} draws MEN | AT | WORK boxes it has no Laborer to work`);
assert.ok(boxes.includes('green') && boxes.includes('red'), `a ${tier} should still show passengers waiting and arrived`);
// And it must not borrow an industry's word for what it does, nor a siding it has no track for.
assert.match(svg, /BOARDS \+ ALIGHTS/, `a ${tier} is labelled like an industry`);
assert.doesNotMatch(svg, /SHIPS|RECEIVES/, `a ${tier} is labelled like an industry`);
assert.equal(
[...svg.matchAll(/class="bs-slot/g)].length, 0,
`a ${tier} draws a siding slot, but a passenger facility has no industry track`,
);
}
});
it('paints inbound boxes red in the side panel, not green', () => {
// REPORTED from playtesting. The panel's shared box helper used ONE class for every filled box,
// so the inbound row rendered green while the board SVG on the same screen drew it red — the two
// views of one card disagreeing about the colour code at the same moment.
const game = newGame(5);
const area = game.state.officeAreas.get(0)!;
area.grid.set('-1,0', {
geometry: { kind: 'facility', facility: 'grocersWarehouse' },
baseOperationalRail: true, standing: [], modifiers: [], enhancements: [],
facility: {
kind: 'freight', subtype: 'grocersWarehouse',
allows: { outbound: false, inbound: true },
outboundBox: [], inboundBox: [{ type: 'boxcar', loaded: true }],
capacity: { outbound: 0, inbound: 1 },
menAtWork: [null, null, null], industryTrack: { length: 1, cars: [] },
laborers: 1, porters: 0, usedThisStage: { laborers: 0, porters: 0 },
},
} as never);
const html = facilitiesHtml(view(game));
assert.match(html, /class="box r"/, 'a cleared inbound load is not painted red');
assert.doesNotMatch(html, /class="box f"/, 'the direction-blind box class is still in use');
});
it('names where the work has got to, in the tooltip', () => {
const game = newGame(5);
const area = game.state.officeAreas.get(0)!;
@@ -755,7 +907,14 @@ describe('the page explains itself', () => {
// action that did nothing.
const game = newGame(111);
submit(game, actionGroups(game).options.find((o) => o.type === 'localOps.choose' && o.option === 'draw')!);
submit(game, actionGroups(game).options.find((o) => o.type === 'draw.fromDepartment' && o.slot === 0)!);
// A Station upgrade is PUT in hand rather than drawn for. This used to take whatever seed 111
// happened to deal, which made it luck: the moment deck composition changed it dealt no upgrade
// and the test failed without anything being wrong.
const station = [...game.state.cards.entries()].find(
([, c]) => c.kind.kind === 'office' && c.kind.tier === 'station',
);
assert.ok(station, 'no Station card in the deck');
game.state.decks.hands.set(0, [station![0]]);
const f = view(game);
const playable = handPlayable(game);
@@ -764,7 +923,7 @@ describe('the page explains itself', () => {
const upgrades = f.hand
.map((name, i) => ({ name, what: f.handWhat[i]!, can: playable[i]! }))
.filter((c) => /upgrade/.test(c.name));
assert.ok(upgrades.length > 0, 'this seed should deal an Office upgrade');
assert.ok(upgrades.length > 0, 'no Office upgrade in hand — the fixture failed to place one');
for (const u of upgrades) {
// Only Depot is reachable from a Whistle Post.
if (/Depot/.test(u.name)) continue;
@@ -1439,7 +1598,7 @@ describe('the static build', () => {
const { cardId } = dealTrack(game, 'curved', 'left');
const entry = actionMenu(game).hand.find((h) => h.cardId === cardId);
assert.ok(entry, 'the curve is not in the hand menu');
assert.deepEqual(entry!.shapes, [['sw'], ['ne']], 'both orientations must be previewable');
assert.deepEqual(entry!.shapes, [['se'], ['nw']], 'both orientations must be previewable');
assert.equal(entry!.spots, 0, 'this is the case worth guarding: nowhere legal to lay it');
});
@@ -1464,6 +1623,12 @@ describe('the static build', () => {
// attaches one when the label happens to contain an em-dash.
const game = newGame(775569289);
submit(game, actionGroups(game).options.find((o) => o.type === 'localOps.choose' && o.option === 'draw')!);
// Pin a Mainline card Realignment can actually convert (curves -> plains). Terrain is dealt from
// the same RNG stream as the cards, so leaving this to the seed makes the test luck rather than
// a check — it broke the moment the opening deal changed how far that stream had advanced.
const node = game.state.division.nodes.find((n) => n.kind === 'mainline');
assert.ok(node && node.kind === 'mainline', 'no Mainline card to realign');
if (node.kind === 'mainline') { node.card = 'curves'; node.transits = []; }
for (const [id, c] of game.state.cards) {
if (c.kind.kind === 'mainlineModifier' && c.kind.key === 'realignment') {
game.state.decks.hands.set(0, [id]);
@@ -1553,7 +1718,10 @@ describe('the static build', () => {
const { options } = actionGroups(game);
if (options.length === 0 || !submit(game, options[0]!)) break;
}
assert.ok(worst <= 8, `the action list still reaches ${worst} buttons`);
// Raised 8 -> 10: the widest group is Switching, and the opening deal grew districts from 17.9
// to 20.3 cards, so a crew simply has more squares it can legally reach. That is the list getting
// longer for a good reason rather than the cross-products this test was written to kill.
assert.ok(worst <= 10, `the action list still reaches ${worst} buttons`);
});
it('makes up ONE train at a time, and names that train', () => {
@@ -1746,16 +1914,19 @@ describe('the static build', () => {
// REPORTED: playing a train card rolls 1D12 for its departure Stage and the card simply left the
// hand — the answer arrived only as one line in the history panel. The twelve slots have been in
// the Frame all along and only the standalone replay ever drew them.
// Playing a train card is the mechanism under test, so it is played directly rather than hoped
// for over 900 arbitrary choices — which is what this did until the opening deal changed which
// cards a seed puts in reach.
const game = newGame(430);
let landed: number | null = null;
for (let i = 0; i < 900 && currentActor(game) !== null; i++) {
const { options } = actionGroups(game);
if (options.length === 0 || !submit(game, options[0]!)) break;
if (game.scheduled !== null) {
landed = game.scheduled;
break;
}
}
submit(game, actionGroups(game).options.find((o) => o.type === 'localOps.choose' && o.option === 'draw')!);
const train = [...game.state.cards.entries()].find(
([, c]) => c.kind.kind === 'timetabledTrain' || c.kind.kind === 'extraTrain',
);
assert.ok(train, 'no train card in the deck');
game.state.decks.hands.set(0, [train![0]]);
assert.ok(submit(game, { type: 'card.play', cardId: train![0] }), 'the train card was refused');
const landed = game.scheduled;
assert.ok(landed !== null, 'no train was ever scheduled');
const f = view(game);
assert.ok(f.timetable[landed!] !== null, 'the slot the roll reported is empty');