Say who is playing, what you can press, and where you are in the Stage

Mostly presentation, plus one rules fix found on the way.
This commit is contained in:
Jesse
2026-08-04 08:37:42 -04:00
parent 982952fd6c
commit ec792dfc15
12 changed files with 392 additions and 62 deletions
+51
View File
@@ -635,3 +635,54 @@ describe('an industry handles every commodity its profile names (regression)', (
assert.deepEqual(twoCommodity, ['powerPlant', 'grocersWarehouse']);
});
});
describe('a Modifier only goes beside a host that can use it (regression)', () => {
it('offers a Waiting Area no square that is not beside a Passenger Facility', () => {
// REGRESSION. Every Modifier prints its host — a Waiting Area, Restaurant and Hotel go beside a
// Passenger Facility; Forklifts beside a Freight House or Packing Sheds — and the placement
// check ignored all of it, asking only "does this square touch ANY facility?". A Waiting Area
// was therefore legal beside a Mine Tipple, and `applyModifier` handed its extra Porter to
// whichever facility the scan happened to reach first. The player was shown three legal spots
// for a card that has one.
const s = game();
const area = areaOf(s, 0);
// A freight industry away from the Office, and nothing else nearby.
area.grid.set(coordKey({ row: -1, col: 4 }), {
geometry: { kind: 'facility', facility: 'mineTipple', axis: 'ew' },
baseOperationalRail: true, standing: [], modifiers: [], enhancements: [],
facility: {
kind: 'freight', subtype: 'mineTipple',
allows: { outbound: true, inbound: false },
outboundBox: [], inboundBox: [], capacity: { outbound: 1, inbound: 0 },
menAtWork: [null, null, null],
industryTrack: { length: 2, cars: [] },
laborers: 1, porters: 0, usedThisStage: { laborers: 0, porters: 0 },
},
} as never);
const waiting = [...s.cards.entries()].find(
([, c]) => c.kind.kind === 'modifier' && c.kind.modifier === 'waitingArea',
);
assert.ok(waiting, 'no Waiting Area card in the deck');
const [cardId] = waiting!;
s.decks.hands.set(0, [cardId]);
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
const spots = legalActions(s, 0).filter(
(i) => i.type === 'card.play' && i.cardId === cardId && i.placement !== undefined,
);
assert.ok(spots.length > 0, 'a Waiting Area has nowhere legal at all — the Office is a host');
// Every offered square must touch the Office and none may touch only the Mine Tipple.
for (const i of spots) {
const p = (i as { placement: { row: number; col: number } }).placement;
const touchesOffice =
Math.abs(p.row - area.officeCoord.row) <= 1 && Math.abs(p.col - area.officeCoord.col) <= 1;
assert.ok(
touchesOffice,
`a Waiting Area was offered (${p.row}, ${p.col}), which is not beside a Passenger Facility`,
);
}
});
});
+5 -2
View File
@@ -78,7 +78,7 @@ describe('narration', () => {
const n = narrate(e);
assert.ok(n.text.length > 3, `${e.type} produced no useful text`);
assert.ok(!/^\[/.test(n.text), `${e.type} fell through to a fallback`);
assert.ok(['plain', 'good', 'bad', 'clock', 'quiet'].includes(n.tone), `${e.type} bad tone`);
assert.ok(['plain', 'good', 'bad', 'clock', 'quiet', 'phase'].includes(n.tone), `${e.type} bad tone`);
}
});
@@ -98,8 +98,11 @@ describe('narration', () => {
});
it('names phases in words rather than identifiers', () => {
assert.equal(phaseLabel('loadUnload'), 'Load / Unload');
assert.equal(phaseLabel('loadUnload'), 'Cargo');
assert.equal(phaseLabel('newTrain'), 'New Train');
assert.equal(phaseLabel('shiftChange'), 'Supervisor Shift');
assert.equal(phaseLabel('localOps'), 'Local Operations');
assert.equal(phaseLabel('mainline'), 'Mainline');
});
it('hides only the events with nothing to show', () => {
+41 -8
View File
@@ -148,13 +148,21 @@ describe('the action menu presents choices the way they are made', () => {
// "slot 2" is unusable information: the whole point of a face-up slot is choosing it on sight.
const game = newGame(555);
submit(game, actionGroups(game).options.find((o) => o.type === 'localOps.choose' && o.option === 'draw')!);
const draw = actionMenu(game).direct.find((g) => g.title === 'Draw');
assert.ok(draw, 'no draw group');
for (const a of draw!.actions) {
assert.ok(!/^draw\.|^slot \d+$/.test(a.label), `raw intent name on a button: ${a.label}`);
// The draw actions are split by SOURCE now — gambling on the deck, taking a named face-up
// card, and ending the turn are three different decisions — so gather every group that offers
// one rather than looking for a single group called "Draw".
const menu = actionMenu(game);
const draws = menu.direct.filter((g) =>
g.actions.some((a) => menu.options[a.index]?.type?.startsWith('draw.')),
);
assert.ok(draws.length > 0, 'no draw actions are offered at all');
for (const g of draws) {
for (const a of g.actions) {
assert.ok(!/^draw\.|^slot \d+$/.test(a.label), `raw intent name on a button: ${a.label}`);
}
}
assert.ok(
draw!.actions.some((a) => /face-up slot/.test(a.label)),
draws.flatMap((g) => g.actions).some((a) => /face-up slot/.test(a.label)),
'face-up slots are not named',
);
});
@@ -743,8 +751,10 @@ describe('the static build', () => {
// STRICT: only ids that really exist in the served page. The stub used to conjure an element
// for any id asked for, so a `$('target')` left behind after removing #target from the HTML
// would pass here and throw on load in a browser — the page would simply never start.
// `[\w-]` and not `[a-zA-Z]`: a hyphenated id is valid HTML, and the narrower pattern skipped
// those silently — which would have let a genuinely MISSING `#tc-day` pass this test too.
const served = new Set(
[...readFileSync(join(dist, 'play.html'), 'utf8').matchAll(/id="([a-zA-Z]+)"/g)].map(
[...readFileSync(join(dist, 'play.html'), 'utf8').matchAll(/id="([a-zA-Z][\w-]*)"/g)].map(
(m) => m[1]!,
),
);
@@ -867,9 +877,9 @@ describe('the static build', () => {
// Cheap and total: compare every $('id') in the source against the ids in the served HTML.
// Getting this wrong does not degrade the page, it stops the game starting at all.
const src = readFileSync(join(root, 'src/web/main.ts'), 'utf8');
const asked = new Set([...src.matchAll(/\$\('([a-zA-Z]+)'\)/g)].map((m) => m[1]!));
const asked = new Set([...src.matchAll(/\$\('([a-zA-Z][\w-]*)'\)/g)].map((m) => m[1]!));
const html = readFileSync(join(dist, 'play.html'), 'utf8');
const present = new Set([...html.matchAll(/id="([a-zA-Z]+)"/g)].map((m) => m[1]!));
const present = new Set([...html.matchAll(/id="([a-zA-Z][\w-]*)"/g)].map((m) => m[1]!));
// `again` is created by the game-over screen before it is looked up.
present.add('again');
for (const id of asked) {
@@ -877,6 +887,29 @@ describe('the static build', () => {
}
});
it('gives every phase the engine can be in a pill on the turn chart', () => {
// The turn chart exists to say where in the Stage you are, so a phase with no pill is a hole
// exactly when the player most needs it. `Phase` is a closed union in the engine; this fails if
// one is added and the chart is not.
const engine = readFileSync(join(root, 'src/engine/state.ts'), 'utf8');
const decl = /export type Phase =([^;]+);/.exec(engine);
assert.ok(decl, 'the Phase union moved — this test cannot see it any more');
const phases = [...decl![1]!.matchAll(/'([a-zA-Z]+)'/g)].map((m) => m[1]!);
assert.ok(phases.length >= 5, `only found ${phases.length} phases`);
const src = readFileSync(join(root, 'src/web/main.ts'), 'utf8');
for (const p of phases) {
assert.match(src, new RegExp(`key: '${p}'`), `no turn-chart pill for the ${p} phase`);
}
// And every pill must carry a tooltip: an icon alone does not explain a phase.
const tips = [...src.matchAll(/key: '[a-zA-Z]+',\s*\n\s*label: '[^']+',\s*\n\s*tip: '/g)];
assert.equal(tips.length, phases.length, 'a turn-chart pill has no tooltip');
const html = readFileSync(join(dist, 'play.html'), 'utf8');
assert.match(html, /id="turnchart"/, 'the page has no turn chart');
assert.doesNotMatch(html, /phase: <b id="phase">/, 'the old title-bar phase text is still there');
});
it('serves all three pages, each stamped and cache-busted', () => {
// The splash is the front door now; the game and the replay directory are separate pages.
for (const [name, entry] of [