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
+65 -1
View File
@@ -154,6 +154,70 @@ missing one just made it a different form that happened to share a rules block.
The drift guard that had been checking three prefixes now checks two, and a new assertion fails if The drift guard that had been checking three prefixes now checks two, and a new assertion fails if
any `ng-` id ever reappears. any `ng-` id ever reappears.
### The two screens are worded the same, section by section
Jesse, 2026-08-30: "let's get the text between the Multiplayer and solitaire as close as possible to
the same." Walked section by section; where the two said the same thing differently, the lobby's
wording won, because it was written for the harder case.
- **The chair note is one paragraph on both**, replacing two that each described only their own
case: "Every chair must be filled before the game can start. For solitaire, there's only one
player. For multiplayer, that must be filled by a person or a bot. The number of players cannot be
changed once the game is created."
- **The Game type heading names which game the screen deals** — "Game type (multi-player)" and
"Game type (solitaire)". That heading is now the whole explanation for the dimmed rows.
- **No reason is printed beside a dimmed type any more.** The lobby appended "— dealt with the New
game button, not here" to Solitaire, and the solitaire screen appended "— use the Multiplayer
button; a table needs a server" to each of three. Jesse: "grayed out with no additional
explanation. The explanation above… is sufficient." One heading says it once; three rows were
saying it three times. `markUnavailable` is down to dimming, and `.lb-why` is gone with them.
- **The sentence under the radios is deleted on both.** It restated the type just chosen to the
person who had just chosen it. Its one non-obvious case — how many settings a Custom game differs
by — is not lost: `form.mark` already puts "Co-op default: …" on each row that differs, which is
where a reader can act on it rather than a count they would have to go and find. The tests that
read that sentence now read the row hints instead.
- **The Game settings, Starting hand and Victory conditions notes are the lobby's on both**, with
one edit Jesse asked for: "clicking a type again resets" → "changing the game type resets", which
names the control that does it rather than a gesture. Victory conditions was not on his list —
applied under the same rule, and the lobby's wording fits now that solitaire also shows the table
size.
What is deliberately still different: each screen's own opening paragraph, and the heading above.
### The status line said nobody was holding the game up
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 the three
interruptions that stop the game and put a question to a named person (§8.1's clearance ruling,
Gitea#5's Yard Office offer, Gitea#19's Red Flag prompt) all reported that the Division was running
itself. `actingPlayer` has had the answer since the Gitea#5 refactor; the Frame threw it away.
It carries `actingPlayer` now, **and what the question is**: "waiting on **Bob** · a clearance ruling
· Train 4". Naming the person is not enough on its own — three different things can be pending, and
"waiting on Bob" with nothing after it is a game that looks stuck to everyone except Bob.
### The Fedora moved to the end of the phase row
`TODO.md` #29. It sat on a line of its own between the phase chips and everything above them, which
put a thing that moves every third Stage in among the things that move every Stage. It rides at the
right-hand end of the phase row now — the row whose last chip is Supervisor Shift, the phase that
passes it — and wraps underneath rather than squeezing the chips on a narrow screen.
### The developer replay stopped printing a raw enum
`TODO.md` #34. Its 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 it. It reads "lost — The Division closed short: 3 Revenue between everyone, against a
floor of 15…" now.
`panels.ts`'s `reasonSentence` is exported and shared rather than reimplemented, so the replay and
the results screen cannot explain one ending two different ways. It is fed the last recorded frame —
the state the outcome was decided in — and stripped of markup, since it lands in an `<h1>` and a
console line. The drift test that compared this string against the engine now maps `win`/`loss` to
`won`/`lost`, so it still checks the two agree rather than that they are spelled alike.
### Three wording and layout fixes ### Three wording and layout fixes
- **The collision entries** on all three screens now read "The game ends immediately and results in - **The collision entries** on all three screens now read "The game ends immediately and results in
@@ -168,7 +232,7 @@ any `ng-` id ever reappears.
cost, not a danger, and red would outrank the actual rules above it. The buttons say **Continue cost, not a danger, and red would outrank the actual rules above it. The buttons say **Continue
Existing Saved Game** and **Deal New Game** rather than "Continue saved game" and "Deal". Existing Saved Game** and **Deal New Game** rather than "Continue saved game" and "Deal".
874 tests pass, seven of them new; one existing test asserted the opposite of the collision ruling 877 tests pass, ten of them new; one existing test asserted the opposite of the collision ruling
above and says so where it was reversed. above and says so where it was reversed.
--- ---
+20 -3
View File
@@ -168,8 +168,10 @@ pass as 19-21 and 24 above, and all three are answered by Gitea#18 rather than f
28. **INVESTIGATE: move the game's settings off the top line and into a card of their own** — and 28. **INVESTIGATE: move the game's settings off the top line and into a card of their own** — and
show ALL of them, not the four that fit. Reasoning in Display below. show ALL of them, not the four that fit. Reasoning in Display below.
29. **Put the Fedora at the right-hand end of the phase row**, with (or in) the Supervisor Shift 29. ~~**Put the Fedora at the right-hand end of the phase row**~~ — done 2026-08-30 in v0.7.9. It
pill. Reasoning in Display below. rides at the end of the phase row now, and wraps under it rather than squeezing the chips on a
narrow screen. Went in beside the "waiting on nobody" fix below, since both are the top few rows
of status that Jesse asked to have re-looked at as a whole.
Queued 2026-08-25, from releasing 0.7.1 / 0.4.9g. **Both are blocked on the two commits being made Queued 2026-08-25, from releasing 0.7.1 / 0.4.9g. **Both are blocked on the two commits being made
and pushed** — they were still uncommitted when the session ended. and pushed** — they were still uncommitted when the session ended.
@@ -218,7 +220,12 @@ Queued 2026-08-29, from building Gitea#11 and #16 (both shipped in v0.7.3, main
rather than recorded, a second pass can add any of them retroactively to games already played and rather than recorded, a second pass can add any of them retroactively to games already played and
saved. Needs Jesse and a conversation, not code, to start. saved. Needs Jesse and a conversation, not code, to start.
34. **`replay.ts` prints a raw outcome enum, exactly as the results screen used to.** Its summary 34. ~~**`replay.ts` prints a raw outcome enum, exactly as the results screen used to.**~~ — done
2026-08-30 in v0.7.9. `panels.ts`'s `reasonSentence` is exported and shared, fed the last
recorded frame and stripped of markup; the drift test now maps `win`/`loss` to `won`/`lost` so
it still checks agreement rather than spelling. Original note below.
**`replay.ts` printed a raw outcome enum, exactly as the results screen used to.** Its summary
line is `` `${o.result} — ${o.reason}` ``, which renders "loss — revenueFloor" — the same defect line is `` `${o.result} — ${o.reason}` ``, which renders "loss — revenueFloor" — the same defect
Gitea#16 was filed about, in the dev-side replay viewer rather than the playable page. The Gitea#16 was filed about, in the dev-side replay viewer rather than the playable page. The
sentences now exist (`panels.ts`'s `reasonSentence`), but they are written against a `Frame` and sentences now exist (`panels.ts`'s `reasonSentence`), but they are written against a `Frame` and
@@ -359,6 +366,16 @@ Queued 2026-08-29, from building Gitea#11 and #16 (both shipped in v0.7.3, main
fields and confirmed the dealt game matches what was chosen. Worth being an early item in the fields and confirmed the dealt game matches what was chosen. Worth being an early item in the
next play session, alongside #39's four unplayed v0.7.4 features. next play session, alongside #39's four unplayed v0.7.4 features.
43. ~~**"Waiting on" said nobody while the game was stopped on the Superintendent.**~~ — done
2026-08-30 in v0.7.9. Reported by Jesse from play. `Frame.actor` carried `clock.currentActor`,
null for the whole Mainline Phase, so all three interruptions (clearance, Yard Office, Red Flag)
reported that the Division was running itself. It carries `actingPlayer` now and a new
`awaiting` field says what the question is and which train it is about.
**Worth knowing for the next Frame field:** the engine had the right answer in `actingPlayer`
since the Gitea#5 refactor, and the Frame simply did not carry it. A view that reads one field
of `clock` directly is a place this can happen again.
--- ---
## Replay / Save Games ## Replay / Save Games
+16 -1
View File
@@ -35,6 +35,7 @@ import { legalActions } from '../engine/legal.ts';
import { createGame } from '../engine/setup.ts'; import { createGame } from '../engine/setup.ts';
import type { Facility, GameConfig, GameState } from '../engine/state.ts'; import type { Facility, GameConfig, GameState } from '../engine/state.ts';
import { actingPlayer } from '../engine/state.ts'; import { actingPlayer } from '../engine/state.ts';
import { reasonSentence } from '../web/panels.ts';
import { developerBot, lastChoiceReason } from './bot.ts'; import { developerBot, lastChoiceReason } from './bot.ts';
import { carLabel, cuesFor, idleNote, isVisible, narrate } from './narrate.ts'; import { carLabel, cuesFor, idleNote, isVisible, narrate } from './narrate.ts';
// The view-model lives in its own module so the browser build can import it without dragging in // The view-model lives in its own module so the browser build can import it without dragging in
@@ -148,12 +149,26 @@ export function record(seed: number, length: GameLength, maxSteps = 100_000): Re
push(applied.events); push(applied.events);
} }
/**
* IN WORDS, NOT AS AN ENUM (`TODO.md` #34). This heading read `loss — revenueFloor`, which is
* exactly the defect Gitea#16 was filed about on the playable page — it just outlived the fix
* here, because nothing player-facing pointed at it. `reasonSentence` is shared rather than
* reimplemented, so the replay and the results screen cannot end up explaining the same ending
* two different ways.
*
* Fed the LAST frame, which is the state the outcome was decided in and is already recorded.
* Tags are stripped: this lands in an `<h1>` and in a console line, neither of which wants markup.
*/
const o = s.outcome; const o = s.outcome;
const last = frames[frames.length - 1];
const why = o && last ? reasonSentence(last, o, last.day).replace(/<[^>]+>/g, '') : '';
return { return {
seed, seed,
length, length,
frames, frames,
outcome: o ? `${o.result} — ${o.reason} · final Revenue ${s.players[0]?.revenue ?? 0}` : 'unfinished', outcome: o
? `${o.result === 'win' ? 'won' : 'lost'} — ${why} Final Revenue ${s.players[0]?.revenue ?? 0}.`
: 'unfinished',
}; };
} }
+29 -5
View File
@@ -19,6 +19,8 @@ export type TurnChartFrame = {
phase: string; phase: string;
phaseKey: string; phaseKey: string;
actor: number | null; actor: number | null;
/** What the game has stopped to ask, when it has. Null while a phase is simply running. */
awaiting?: { asks: string; train: string } | null;
}; };
/** /**
@@ -93,8 +95,21 @@ export function turnChartHtml(f: TurnChartFrame, actorName: string | null, super
); );
}).join(''); }).join('');
// An automatic phase is waiting on nobody, and saying so is more use than a blank. /**
* WAITING ON WHOM, AND FOR WHAT.
*
* "nobody — the Division is running itself" is true of an automatic phase and was being printed
* over the top of three interruptions that are emphatically waiting on a person: §8.1's clearance
* ruling, the Yard Office offer and the Red Flag prompt. The Frame carried the phase's actor,
* which is null throughout the Mainline Phase, so a game stopped on a named player's decision
* reported that nobody was holding it up (Jesse, 2026-08-30). `Frame.actor` is `actingPlayer` now
* and answers who; `awaiting` says what, because "waiting on Bob" with no more than that is a
* game that looks stuck to everyone except Bob.
*/
const who = actorName ?? 'nobody — the Division is running itself'; const who = actorName ?? 'nobody — the Division is running itself';
const asked = f.awaiting
? ` <span class="tc-asks">${esc(f.awaiting.asks)} · ${esc(f.awaiting.train)}</span>`
: '';
const fedora = const fedora =
superName === null superName === null
? '' ? ''
@@ -105,9 +120,12 @@ export function turnChartHtml(f: TurnChartFrame, actorName: string | null, super
`<div class="tc-when"><b>Day ${f.day}</b><span>Stage ${f.stage} of 12</span>` + `<div class="tc-when"><b>Day ${f.day}</b><span>Stage ${f.stage} of 12</span>` +
`<span class="dim">${esc(f.clock)}</span></div>` + `<span class="dim">${esc(f.clock)}</span></div>` +
`<div class="tc-now">phase <b>${esc(f.phase)}</b></div>` + `<div class="tc-now">phase <b>${esc(f.phase)}</b></div>` +
`<div class="tc-who">waiting on <b>${esc(who)}</b></div>` + `<div class="tc-who">waiting on <b>${esc(who)}</b>${asked}</div>` +
fedora + // THE FEDORA RIDES AT THE END OF THE PHASE ROW (`TODO.md` #29, Jesse). It sat on its own line
`<ol class="tc-phases">${chips}</ol>` // between the phases and everything above them, which put a thing that changes every third
// Stage in the middle of the things that change every Stage. The row it belongs beside is the
// one whose last chip is Supervisor Shift — the phase that passes it.
`<div class="tc-row"><ol class="tc-phases">${chips}</ol>${fedora}</div>`
); );
} }
@@ -130,12 +148,18 @@ export const TURNCHART_CSS = `
says "Player Solitaire", so the chart should agree. In multiplayer this is the thing a table says "Player Solitaire", so the chart should agree. In multiplayer this is the thing a table
glances at most often, so it gets its own chip rather than hiding in the phase text. */ glances at most often, so it gets its own chip rather than hiding in the phase text. */
.tc-who{display:flex;align-items:center;gap:6px;font-size:12px;color:#8b94a3} .tc-who{display:flex;align-items:center;gap:6px;font-size:12px;color:#8b94a3}
.tc-asks{color:#a99ac4;font-style:italic}
.tc-who b{color:#b98cf0;background:rgba(150,110,230,.16);border:1px solid #8b6ad0; .tc-who b{color:#b98cf0;background:rgba(150,110,230,.16);border:1px solid #8b6ad0;
border-radius:11px;padding:1px 9px;font-size:12px} border-radius:11px;padding:1px 9px;font-size:12px}
/* WHO HOLDS THE FEDORA. Violet like the rest of the chart — this is "where you are" news, not /* WHO HOLDS THE FEDORA. Violet like the rest of the chart — this is "where you are" news, not
something to press — but unfilled, so the eye still lands on "waiting on" first: that is the one something to press — but unfilled, so the eye still lands on "waiting on" first: that is the one
that changes every turn, while this changes four times a Day. */ that changes every turn, while this changes four times a Day. */
.tc-super{display:flex;align-items:center;gap:6px;font-size:12px;color:#8b94a3;cursor:help} /* The phase row and the Fedora on one line, the hat pushed to the far end (TODO.md #29): the row
is the Stage, and the Superintendent is who holds it. Wraps under the phases on a narrow screen
rather than squeezing the chips. */
.tc-row{display:flex;align-items:center;gap:12px;flex-wrap:wrap}
.tc-row ol.tc-phases{flex:1 1 auto}
.tc-super{margin-left:auto;display:flex;align-items:center;gap:6px;font-size:12px;color:#8b94a3;cursor:help}
.tc-super b{color:#cbb6f2;border:1px solid #6b5a94;border-radius:11px;padding:1px 9px;font-size:12px} .tc-super b{color:#cbb6f2;border:1px solid #6b5a94;border-radius:11px;padding:1px 9px;font-size:12px}
ol.tc-phases{display:flex;gap:6px;list-style:none;margin:0;padding:0;flex-wrap:wrap} ol.tc-phases{display:flex;gap:6px;list-style:none;margin:0;padding:0;flex-wrap:wrap}
.tc-phase{display:flex;align-items:center;gap:6px;border:1px solid #2c333d;border-radius:14px; .tc-phase{display:flex;align-items:center;gap:6px;border:1px solid #2c333d;border-radius:14px;
+28 -2
View File
@@ -47,7 +47,7 @@ import {
} from '../engine/content.ts'; } from '../engine/content.ts';
import type { Intent } from '../engine/intents.ts'; import type { Intent } from '../engine/intents.ts';
import type { Facility, GameConfig, GameState, PlayerIndex, SeatIndex, TrackCard, TurnoutOrientation } from '../engine/state.ts'; import type { Facility, GameConfig, GameState, PlayerIndex, SeatIndex, TrackCard, TurnoutOrientation } from '../engine/state.ts';
import { carsOn, playerAtSeat, railFacingOf, seatOf, turnOf } from '../engine/state.ts'; import { actingPlayer, carsOn, playerAtSeat, railFacingOf, seatOf, turnOf } from '../engine/state.ts';
import type { Hand, HouseRules, TrackGeometry } from '../engine/content.ts'; import type { Hand, HouseRules, TrackGeometry } from '../engine/content.ts';
import type { Port } from '../engine/track.ts'; import type { Port } from '../engine/track.ts';
import { connectionsFor, slopeOfPair, variantsFor } from '../engine/track.ts'; import { connectionsFor, slopeOfPair, variantsFor } from '../engine/track.ts';
@@ -354,7 +354,21 @@ export type Frame = {
* replay recorder, which sees the events; the live game keeps its own on the Game object. * replay recorder, which sees the events; the live game keeps its own on the Game object.
*/ */
cues?: string[]; cues?: string[];
/**
* WHO THE GAME IS WAITING ON — the phase's actor, or the owner of a pending interruption when
* there is one. It carried `clock.currentActor` alone until 2026-08-30, which is null during the
* Mainline Phase, so a game stopped dead on a Superintendent's clearance ruling reported "waiting
* on nobody — the Division is running itself" while it waited on a named person to click
* (reported by Jesse). The engine had the answer the whole time in `actingPlayer`.
*/
actor: number | null; actor: number | null;
/**
* WHAT that player is being asked, when the game is stopped on a question rather than a turn.
* Null whenever the phase is simply running. Naming the person is not enough on its own: three
* different interruptions can be waiting, and "waiting on Bob" with no more than that is a game
* that looks stuck to everyone except Bob.
*/
awaiting: { asks: string; train: string } | null;
superintendent: number; superintendent: number;
revenue: number; revenue: number;
/** /**
@@ -1342,7 +1356,19 @@ export function snapshot(
clock: clockTime(s.clock.stage), clock: clockTime(s.clock.stage),
phase: phaseLabel(s.clock.phase), phase: phaseLabel(s.clock.phase),
phaseKey: s.clock.phase, phaseKey: s.clock.phase,
actor: s.clock.currentActor, actor: actingPlayer(s),
/**
* The three interruptions §8.1 and Gitea#5/#19 can raise, said in the words the prompt itself
* uses. `decisionActor` above decides WHO; this is only what they are looking at.
*/
awaiting: (() => {
const d = s.clock.pendingDecision;
if (!d) return null;
const train = trainName(s, d.train);
if (d.kind === 'clearance') return { asks: 'a clearance ruling', train };
if (d.kind === 'yardOffice') return { asks: 'the Yard Office offer', train };
return { asks: 'a Red Flag', train };
})(),
superintendent: s.clock.superintendent, superintendent: s.clock.superintendent,
revenue: s.players[viewer]?.revenue ?? 0, revenue: s.players[viewer]?.revenue ?? 0,
lines, lines,
+18 -25
View File
@@ -256,25 +256,21 @@ export function runLobby(handlers: LobbyHandlers, resume?: { token: string; game
else if (type === 'custom') type = base; else if (type === 'custom') type = base;
for (const r of typeRadios()) r.checked = r.value === type; for (const r of typeRadios()) r.checked = r.value === type;
const scoring = preset(base).scoring; /**
const note = $('lb-type-note'); * NO SENTENCE UNDER THE RADIOS since 2026-08-30 — it restated the type just chosen to the person
if (type === 'custom') { * who had just chosen it, and the row is already labelled and already carries its own
note.textContent = * description (Jesse: "There's no need to repeat it below"). `form.mark` still puts a hint on
`${gameTypeLabel('custom', scoring)} · ${differing.length} ` + * each row that actually differs, which is where a Custom game's differences can be acted on.
`${differing.length === 1 ? 'setting differs' : 'settings differ'} from ${preset(base).label}.`; */
note.className = 'ng-note changed-note'; // A Custom game is nobody's default: open the block that says how it differs.
// A Custom game is nobody's default: open the block that says how it differs. if (type === 'custom') $<HTMLDetailsElement>('lb-settings').open = true;
$<HTMLDetailsElement>('lb-settings').open = true;
} else {
note.textContent = preset(type as PresetName).blurb;
note.className = 'ng-note';
}
} }
for (const r of typeRadios()) { for (const r of typeRadios()) {
// Solitaire is on this screen so the two screens read as one list, but there is nothing here to // Solitaire is on this screen so the two screens read as one list, but there is nothing here to
// deal it with — the New Game dialog is where a solitaire game comes from. // deal it with. Dimmed and left to speak for itself: the heading says "Game type
if (r.value === 'solitaire') markUnavailable(r, 'dealt with the New game button, not here'); // (multi-player)", which is the explanation (Jesse, 2026-08-30).
if (r.value === 'solitaire') markUnavailable(r);
r.onchange = () => { r.onchange = () => {
if (!r.checked) return; if (!r.checked) return;
if (r.value === 'custom') { if (r.value === 'custom') {
@@ -615,18 +611,15 @@ export function prefillCode(code: string): void {
* *
* Reported by Jesse 2026-08-23: "solitaire is disabled, but really hard to tell." A bare `disabled` * Reported by Jesse 2026-08-23: "solitaire is disabled, but really hard to tell." A bare `disabled`
* on a radio leaves the whole row at full strength — the dot simply refuses the click, which reads * on a radio leaves the whole row at full strength — the dot simply refuses the click, which reads
* as a broken control rather than an unavailable one. Dims the row and says why, once. * as a broken control rather than an unavailable one.
*
* The dimming is the whole signal now. It used to append a reason to the row as well, and dropped
* that in 2026-08-30 along with the same text on the solitaire screen: one heading naming which
* game the screen deals says it once, where three dimmed rows each said it again.
*/ */
function markUnavailable(radio: HTMLInputElement, why: string): void { function markUnavailable(radio: HTMLInputElement): void {
radio.disabled = true; radio.disabled = true;
const row = radio.closest('label'); radio.closest('label')?.classList.add('disabled');
if (!row) return;
row.classList.add('disabled');
if (row.querySelector('.lb-why')) return;
const note = document.createElement('span');
note.className = 'lb-why';
note.textContent = ` — ${why}`;
row.querySelector('span')?.appendChild(note);
} }
function escapeHtml(s: string): string { function escapeHtml(s: string): string {
+23 -17
View File
@@ -2019,12 +2019,16 @@ function wireGameTypeBlock(prefix: string, root: ParentNode): WiredGameType {
if (differing.length > 0) type = 'custom'; if (differing.length > 0) type = 'custom';
else if (type === 'custom') type = base; else if (type === 'custom') type = base;
for (const r of typeRadios()) r.checked = r.value === type; for (const r of typeRadios()) r.checked = r.value === type;
const note = field<HTMLElement>('type-note'); /**
note.textContent = * NO SENTENCE UNDER THE RADIOS. It restated the type just chosen — the row is already labelled
type === 'custom' * and already carries its own one-line description — so it was the choice read back to the
? `${gameTypeLabel('custom', preset(base).scoring)} · ${differing.length} ` + * person who had just made it (Jesse, 2026-08-30: "It's obvious from what they selected above
`${differing.length === 1 ? 'setting differs' : 'settings differ'} from ${preset(base).label}.` * what they're playing. There's no need to repeat it below.").
: preset(type as PresetName).blurb; *
* The Custom case said something the radios do NOT — how many settings differ, and from which
* type — and that is not lost: `form.mark` puts a hint on each row that actually differs, which
* is where a reader can act on it rather than a count they would then have to go and find.
*/
} }
function selectPreset(name: PresetName): void { function selectPreset(name: PresetName): void {
@@ -2043,19 +2047,21 @@ function wireGameTypeBlock(prefix: string, root: ParentNode): WiredGameType {
} }
for (const r of typeRadios()) { for (const r of typeRadios()) {
// Nothing here can deal a multiplayer game: a `LocalSession` runs the engine in this browser and /**
// a table needs a server. The lobby is the door, and the row says so rather than just refusing * Nothing here can deal a multiplayer game: a `LocalSession` runs the engine in this browser and
// the click (Jesse, 2026-08-23 — a disabled radio that looks enabled reads as a broken one). * a table needs a server. Dimmed rather than hidden, so what this screen offers and what the
* lobby offers read as one list (Jesse, 2026-08-23 — a disabled radio that looks enabled reads
* as a broken one).
*
* NO REASON PRINTED BESIDE THEM since 2026-08-30. Each row used to gain "— use the Multiplayer
* button; a table needs a server", which is three unreachable types each explaining the same
* thing on a screen whose heading already says "Game type (solitaire)". Jesse: "grayed out with
* no additional explanation. The explanation above… is sufficient." The lobby dims Solitaire the
* same way and says nothing either, which is what lets one list serve both screens.
*/
if (r.value !== 'solitaire' && r.value !== 'custom') { if (r.value !== 'solitaire' && r.value !== 'custom') {
r.disabled = true; r.disabled = true;
const row = r.closest('label'); r.closest('label')?.classList.add('disabled');
if (row && !row.querySelector('.lb-why')) {
row.classList.add('disabled');
const note = document.createElement('span');
note.className = 'lb-why';
note.textContent = ' — use the Multiplayer button; a table needs a server';
row.querySelector('span')?.appendChild(note);
}
} }
r.onchange = () => { r.onchange = () => {
if (!r.checked) return; if (!r.checked) return;
+6 -1
View File
@@ -254,8 +254,13 @@ function collisionsHtml(f: Frame): string {
* game read the words `GAME OVER — revenueFloor`: an internal enum value, printed at the one moment * game read the words `GAME OVER — revenueFloor`: an internal enum value, printed at the one moment
* the game has the player's whole attention. Each reason gets a sentence that says what actually * the game has the player's whole attention. Each reason gets a sentence that says what actually
* happened, with this game's own numbers in it. * happened, with this game's own numbers in it.
*
* EXPORTED for the developer replay recorder (`sim/replay.ts`), which was still printing
* `loss — revenueFloor` into its own heading a release after this was written — the same defect the
* issue was filed about, surviving in the one place nobody had looked (`TODO.md` #34). One
* implementation, so the two cannot say the game ended for different reasons.
*/ */
function reasonSentence(f: Frame, o: NonNullable<Frame['outcome']>, day: number): string { export function reasonSentence(f: Frame, o: NonNullable<Frame['outcome']>, day: number): string {
const combined = f.players.reduce((n, p) => n + p.revenue, 0); const combined = f.players.reduce((n, p) => n + p.revenue, 0);
switch (o.reason) { switch (o.reason) {
case 'daysElapsed': case 'daysElapsed':
+18 -15
View File
@@ -106,7 +106,6 @@ section{background:var(--panel);border:1px solid var(--line);border-radius:7px;
lobby and nothing on it said so — a radio that silently refuses reads as a broken radio. */ lobby and nothing on it said so — a radio that silently refuses reads as a broken radio. */
.ng-radio.disabled{opacity:.45;cursor:not-allowed} .ng-radio.disabled{opacity:.45;cursor:not-allowed}
.ng-radio.disabled:hover{background:none} .ng-radio.disabled:hover{background:none}
.lb-why{color:#e0b060;font-size:11px}
#lobby h2,#solitairesetup h2{margin-top:0} #lobby h2,#solitairesetup h2{margin-top:0}
#lobby h3,#solitairesetup h3{margin-bottom:2px} #lobby h3,#solitairesetup h3{margin-bottom:2px}
.lb-seat{display:flex;align-items:center;gap:8px;padding:5px 0;border-bottom:1px solid var(--line)} .lb-seat{display:flex;align-items:center;gap:8px;padding:5px 0;border-bottom:1px solid var(--line)}
@@ -414,12 +413,13 @@ ul.blocked li{padding:2px 0}
can be shared, compared or replayed. Leave it blank for a random one.</p> can be shared, compared or replayed. Leave it blank for a random one.</p>
<!-- WITH THE TABLE SIZE IT IS ABOUT, not below the rules block — reported by Jesse, who found <!-- WITH THE TABLE SIZE IT IS ABOUT, not below the rules block — reported by Jesse, who found
it separated from the control it explains by fifteen settings. --> it separated from the control it explains by fifteen settings. -->
<p class="ng-note">Every chair has to be taken before the game can start — by a person or by a <p class="ng-note">Every chair must be filled before the game can start. For solitaire,
bot. Pick the size of the table now; it cannot change once the game is created.</p> there&rsquo;s only one player. For multiplayer, that must be filled by a person or a
bot. The number of players cannot be changed once the game is created.</p>
</div> </div>
<div class="lb-col"> <div class="lb-col">
<h3>Game type</h3> <h3>Game type (multi-player)</h3>
<div class="set-row" id="lb-type-row"> <div class="set-row" id="lb-type-row">
<label class="ng-radio"><input type="radio" name="lb-type" value="solitaire"> <label class="ng-radio"><input type="radio" name="lb-type" value="solitaire">
<span><b>Solitaire</b><br><span class="dim">One railroad, one player. The whole Division is yours to run.</span></span></label> <span><b>Solitaire</b><br><span class="dim">One railroad, one player. The whole Division is yours to run.</span></span></label>
@@ -433,7 +433,7 @@ ul.blocked li{padding:2px 0}
<span><b>Custom</b><br><span class="dim">Whatever you set below. Selected for you the moment you change a rule; it is scored as the type you started from.</span></span></label> <span><b>Custom</b><br><span class="dim">Whatever you set below. Selected for you the moment you change a rule; it is scored as the type you started from.</span></span></label>
</div> </div>
<p class="ng-note" id="lb-type-note"></p>
</div> </div>
<!-- FULL WIDTH WHEN IT OPENS. Reported by Jesse: opened inside the right-hand column it made a <!-- FULL WIDTH WHEN IT OPENS. Reported by Jesse: opened inside the right-hand column it made a
@@ -443,7 +443,7 @@ ul.blocked li{padding:2px 0}
<summary>Game settings</summary> <summary>Game settings</summary>
<p class="ng-note">Every rule the game type sets, and every one of them yours to change. <p class="ng-note">Every rule the game type sets, and every one of them yours to change.
Changing any of them selects <b>Custom</b>, which keeps the scoring of the type you Changing any of them selects <b>Custom</b>, which keeps the scoring of the type you
started from; clicking a type again resets all of them back to it. They are fixed when the started from; changing the game type resets all of them back to it. They are fixed when the
game is created and cannot be changed once it starts.</p> game is created and cannot be changed once it starts.</p>
<div class="set-groups"> <div class="set-groups">
@@ -646,10 +646,11 @@ ul.blocked li{padding:2px 0}
</div> </div>
<p class="ng-note">The same seed and the same settings always deal the same railroad, so a game <p class="ng-note">The same seed and the same settings always deal the same railroad, so a game
can be shared, compared or replayed. Leave it blank for a random one.</p> can be shared, compared or replayed. Leave it blank for a random one.</p>
<p class="ng-note">Every chair has to be taken before the game can start — here that is the one <p class="ng-note">Every chair must be filled before the game can start. For solitaire,
chair, and it is yours. To play with other people, use the Multiplayer door instead.</p> there&rsquo;s only one player. For multiplayer, that must be filled by a person or a
bot. The number of players cannot be changed once the game is created.</p>
<h3>Game type</h3> <h3>Game type (solitaire)</h3>
<div class="set-row" id="ss-type-row"> <div class="set-row" id="ss-type-row">
<label class="ng-radio"><input type="radio" name="ss-type" value="solitaire" checked> <label class="ng-radio"><input type="radio" name="ss-type" value="solitaire" checked>
<span><b>Solitaire</b><br><span class="dim">One railroad, one player. The whole Division is yours to run.</span></span></label> <span><b>Solitaire</b><br><span class="dim">One railroad, one player. The whole Division is yours to run.</span></span></label>
@@ -663,19 +664,20 @@ ul.blocked li{padding:2px 0}
<span><b>Custom</b><br><span class="dim">Whatever you set below. Selected for you the moment you change a rule; it is scored as the type you started from.</span></span></label> <span><b>Custom</b><br><span class="dim">Whatever you set below. Selected for you the moment you change a rule; it is scored as the type you started from.</span></span></label>
</div> </div>
<p class="ng-note" id="ss-type-note"></p>
<details id="ss-settings" open> <details id="ss-settings" open>
<summary>Game settings</summary> <summary>Game settings</summary>
<p class="ng-note">Every rule the game type sets, and every one of them yours to change. <p class="ng-note">Every rule the game type sets, and every one of them yours to change.
Changing any of them selects <b>Custom</b>; clicking a type again resets all of them back Changing any of them selects <b>Custom</b>, which keeps the scoring of the type you
to it.</p> started from; changing the game type resets all of them back to it. They are fixed when the
game is created and cannot be changed once it starts.</p>
<div class="set-groups"> <div class="set-groups">
<div class="set-group"> <div class="set-group">
<h3>Starting hand</h3> <h3>Starting hand</h3>
<p class="ng-note">What you are dealt before the first turn. The hand limit is three either <p class="ng-note">What each player is dealt before the first turn. The hand limit is three
way — deal six and the first turn is spent choosing which of them to keep.</p> either way — deal six and the first turn is spent choosing which of them to keep.</p>
<div class="set-row" id="ss-hand-row"> <div class="set-row" id="ss-hand-row">
<label class="ng-radio"><input type="radio" name="ss-hand" value="threeRandom"> <label class="ng-radio"><input type="radio" name="ss-hand" value="threeRandom">
<span><b>Three random cards</b><br><span class="dim">The original rule. At the hand limit already, and no guarantee of track.</span></span></label> <span><b>Three random cards</b><br><span class="dim">The original rule. At the hand limit already, and no guarantee of track.</span></span></label>
@@ -732,7 +734,8 @@ ul.blocked li{padding:2px 0}
<div class="set-group"> <div class="set-group">
<h3>Victory conditions</h3> <h3>Victory conditions</h3>
<p class="ng-note">The ways this game can end badly. How long it runs is set above, in Days.</p> <p class="ng-note">The ways this game can end badly. Each one is switched on or off in its own
right; how long the game runs is set above, with the table size.</p>
<div class="set-row" id="ss-minrev-row"> <div class="set-row" id="ss-minrev-row">
<label class="ng-gate"><input type="checkbox" id="ss-minrev-on" checked> <label class="ng-gate"><input type="checkbox" id="ss-minrev-on" checked>
<span>You lose if Revenue at the end is under</span> <span>You lose if Revenue at the end is under</span>
+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'); 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', () => { it('says nothing about a platform that is working fine', () => {
// Passengers waiting AND a train with an empty coach to take them: no impediment. // Passengers waiting AND a train with an empty coach to take them: no impediment.
const s = createGame({ id: 'g', seed: 5, config, playerNames: ['p'] }); 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]!; const last = rec.frames[rec.frames.length - 1]!;
assert.equal(last.revenue, stats.revenue.net, 'final revenue disagrees with the engine'); 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.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', () => { 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', () => { 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 * 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')!['value'] = '3';
(els.get('lb-freight')!['oninput'] as () => void)(); (els.get('lb-freight')!['oninput'] as () => void)();
assert.equal(chosen(groups, 'lb-type'), 'custom', 'changing a RULE should have selected Custom'); 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/); // The summary sentence under the radios is gone (2026-08-30). What says HOW a Custom game
assert.match(String(els.get('lb-type-note')!['textContent']), /1 setting differs from Co-op/); // 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 () => { 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'); const tuned = await load('?seed=430&transit=4');
(tuned.els.get('newgame')!['onclick'] as () => void)(); (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'); 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( assert.match(
String(tuned.els.get('ss-type-note')!['textContent']), String(tuned.els.get('ss-transit-hint')!['textContent']),
/1 setting differs from Solitaire/, /Solitaire default/,
'the note did not say what differs', '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'); 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 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. // deal, so a disabled button would be answering a question the radios no longer ask.
assert.match( // No reason is printed beside the dimmed rows any more (Jesse, 2026-08-30 — "grayed out with no
readFileSync(join(dist, 'play.html'), 'utf8'), // additional explanation"). The heading is what says which game the screen deals, once, and it
/use the Multiplayer door instead/, // is the same shape on the lobby, which is what lets one list serve both.
'nothing on the setup screen says where a multiplayer game comes from', 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 () => { it('clicking a game type resets every rule below to it, and leaves the parameters alone', async () => {