v0.3.0 — playtest fixes, two new rules, and §7 enforced
This commit is contained in:
+185
-14
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user