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
+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 [