Say who the game is waiting on, and move the Fedora; replay in words

Three items folded into the unshipped v0.7.9.

WAITING ON (reported by Jesse from play). The status line said "nobody —
the Division is running itself" while the game was stopped on the
Superintendent. Frame.actor carried clock.currentActor, which is null
for the whole Mainline Phase, so all three interruptions — §8.1's
clearance ruling, Gitea#5's Yard Office offer, Gitea#19's Red Flag
prompt — reported that nobody was holding it up. actingPlayer had the
answer since the Gitea#5 refactor; the Frame threw it away. It carries
actingPlayer now, plus a new `awaiting` field naming the question and
the train: "waiting on Bob · a clearance ruling · Train 4". Naming the
person alone is not enough when three different things can be pending.

THE FEDORA (TODO #29) rides at the right-hand end of the phase row
instead of a line of its own, and wraps under rather than squeezing the
chips.

THE DEVELOPER REPLAY (TODO #34) printed "loss — revenueFloor", the same
defect Gitea#16 was filed about, still alive because nothing
player-facing pointed at it. panels.ts's reasonSentence is exported and
shared rather than reimplemented, fed the last recorded frame and
stripped of markup. The drift test maps win/loss to won/lost so it still
checks the two AGREE rather than that they are spelled alike.

Also carries the previous, unsigned commit's work: the two setup screens
worded the same section by section.

877 tests pass, ten new.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AdG46Ja2PEDBkpqiDazMoX
This commit is contained in:
Jesse.Markowitz
2026-08-30 09:01:19 -04:00
co-authored by Claude Sonnet 5
parent 131538dc7c
commit a70b7f88f3
11 changed files with 304 additions and 81 deletions
+22 -1
View File
@@ -214,6 +214,24 @@ describe('a blocked platform says why (Gitea#2)', () => {
assert.ok(f.inboundBox.length === 0, 'the red slots were free — the shortage is the only cause');
});
it('says why the game ended in words, not as a raw enum (TODO #34)', () => {
/**
* The heading read `loss — revenueFloor` — the exact defect Gitea#16 was filed about on the
* playable page, still alive here a release after that was fixed, because nothing
* player-facing pointed at the developer replay. It shares `reasonSentence` with the results
* screen now, so the two cannot explain one ending in two ways.
*/
const rec = record(1234, 'standard');
for (const raw of ['revenueFloor', 'daysElapsed', 'collisionFloor']) {
assert.ok(!rec.outcome.includes(raw), `the summary still prints the raw reason "${raw}"`);
}
assert.doesNotMatch(rec.outcome, /<[^>]+>/, 'markup leaked into a heading and a console line');
assert.match(rec.outcome, /Revenue/, 'the summary says nothing about how the game went');
// And the sentence is the shared one, with this game's own numbers in it.
assert.match(rec.outcome, /closed short|last on the timetable|declared unsafe/,
'the ending is not explained in the words the results screen uses');
});
it('says nothing about a platform that is working fine', () => {
// Passengers waiting AND a train with an empty coach to take them: no impediment.
const s = createGame({ id: 'g', seed: 5, config, playerNames: ['p'] });
@@ -257,7 +275,10 @@ describe('replay recording', () => {
const last = rec.frames[rec.frames.length - 1]!;
assert.equal(last.revenue, stats.revenue.net, 'final revenue disagrees with the engine');
assert.equal(last.day, s.clock.day, 'final Day disagrees with the engine');
assert.match(rec.outcome, new RegExp(stats.result));
// The summary says "won"/"lost" rather than the engine's `win`/`loss` (TODO #34 — it is a
// sentence for a reader now, not an enum). Mapped here so this still checks the two AGREE,
// which is what the test is for, rather than checking they are spelled the same.
assert.match(rec.outcome, new RegExp(stats.result === 'win' ? 'won' : 'lost'));
});
it('narrates every frame', () => {
+59 -10
View File
@@ -2549,6 +2549,47 @@ describe('the static build', () => {
}
});
it('names who the game is waiting on when it stops to ask them something', () => {
/**
* REPORTED BY JESSE 2026-08-30: "waiting on shows 'nobody — the Division is running itself' BUT
* the system is actually waiting on the Superintendent."
*
* `Frame.actor` carried `clock.currentActor`, which is null for the whole Mainline Phase — so a
* game stopped dead on a §8.1 clearance ruling said nobody was holding it up, while it waited on
* a named person to click. `actingPlayer` had the answer the whole time; the Frame threw it
* away. Naming them is only half of it: three different interruptions can be pending, and
* "waiting on Bob" alone is a game that looks stuck to everyone except Bob.
*/
const base = { day: 2, stage: 5, clock: '2:20', phase: 'Mainline', phaseKey: 'mainline' };
const idle = turnChartHtml({ ...base, actor: null, awaiting: null }, null, 'Bob');
assert.match(idle, /nobody — the Division is running itself/, 'an automatic phase should say so');
for (const [asks, train] of [
['a clearance ruling', 'Train 4'],
['the Yard Office offer', 'Train 7'],
['a Red Flag', 'Train X18'],
] as [string, string][]) {
const html = turnChartHtml({ ...base, actor: 1, awaiting: { asks, train } }, 'Bob', 'Bob');
assert.doesNotMatch(html, /running itself/, `"${asks}" still reported nobody`);
assert.match(html, /waiting on <b>Bob<\/b>/, `"${asks}" did not name who it waits on`);
assert.match(html, new RegExp(asks.replace(/ /g, ' ')), `"${asks}" did not say what is being asked`);
assert.match(html, new RegExp(train), `"${asks}" did not name the train it is about`);
}
});
it('puts the Fedora at the right-hand end of the phase row (TODO #29)', () => {
// It sat on a line of its own between the phases and everything above them, which put a thing
// that moves every third Stage in among the things that move every Stage. The row it belongs
// beside is the one whose last chip is Supervisor Shift — the phase that passes it.
const frame = { day: 1, stage: 4, clock: '2:00', phase: 'New Train', phaseKey: 'newTrain', actor: 1 };
const html = turnChartHtml(frame, 'Bob', 'Bob');
const row = /<div class="tc-row">([\s\S]*?)<\/div>\s*$/.exec(html)?.[1] ?? '';
assert.match(row, /<ol class="tc-phases">/, 'the phase row is not in the row wrapper');
assert.ok(row.indexOf('tc-super') > row.indexOf('tc-phases'), 'the Fedora is not after the phases');
assert.match(turnChartHtml(frame, 'Bob', null), /tc-row/, 'solitaire lost the row wrapper with the Fedora');
});
it('names the Superintendent at a table, and stays quiet about it in solitaire', () => {
/**
* REPORTED BY JESSE 2026-08-23, playing two-player on StartOS: seat 1 played a train card and
@@ -3865,8 +3906,13 @@ describe('the lobby screen', () => {
els.get('lb-freight')!['value'] = '3';
(els.get('lb-freight')!['oninput'] as () => void)();
assert.equal(chosen(groups, 'lb-type'), 'custom', 'changing a RULE should have selected Custom');
assert.match(String(els.get('lb-type-note')!['textContent']), /scored as Co-op/);
assert.match(String(els.get('lb-type-note')!['textContent']), /1 setting differs from Co-op/);
// The summary sentence under the radios is gone (2026-08-30). What says HOW a Custom game
// differs is the hint on the row that differs, which is where it can be acted on.
assert.match(
String(els.get('lb-freight-hint')!['textContent']),
/Co-op default/,
'the changed row does not say what it was changed from',
);
});
it('clicking a type again resets every rule, and leaves the parameters alone', async () => {
@@ -4229,10 +4275,11 @@ describe('the solitaire setup screen', () => {
const tuned = await load('?seed=430&transit=4');
(tuned.els.get('newgame')!['onclick'] as () => void)();
assert.equal(readForm(tuned.els, tuned.groups).type, 'custom', 'a game paying for transits still read as Solitaire');
// As in the lobby: the row that differs carries the difference, not a sentence under the radios.
assert.match(
String(tuned.els.get('ss-type-note')!['textContent']),
/1 setting differs from Solitaire/,
'the note did not say what differs',
String(tuned.els.get('ss-transit-hint')!['textContent']),
/Solitaire default/,
'the changed row does not say what it was changed from',
);
});
@@ -4245,11 +4292,13 @@ describe('the solitaire setup screen', () => {
assert.deepEqual(disabled, ['coop', 'competitive', 'cutthroat'], 'the wrong game types are dealable here');
// Deal is never disabled here any more: the only types this screen can SELECT are the two it can
// deal, so a disabled button would be answering a question the radios no longer ask.
assert.match(
readFileSync(join(dist, 'play.html'), 'utf8'),
/use the Multiplayer door instead/,
'nothing on the setup screen says where a multiplayer game comes from',
);
// No reason is printed beside the dimmed rows any more (Jesse, 2026-08-30 — "grayed out with no
// additional explanation"). The heading is what says which game the screen deals, once, and it
// is the same shape on the lobby, which is what lets one list serve both.
const page = readFileSync(join(dist, 'play.html'), 'utf8');
assert.match(page, /Game type \(solitaire\)/, 'the setup screen does not name the game it deals');
assert.match(page, /Game type \(multi-player\)/, 'the lobby does not name the game it deals');
assert.ok(!page.includes('lb-why'), 'the per-row reason is back, on the page or in its stylesheet');
});
it('clicking a game type resets every rule below to it, and leaves the parameters alone', async () => {