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:
@@ -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
@@ -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
@@ -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 [
|
||||
|
||||
Reference in New Issue
Block a user