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
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
- **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
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.
---
+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
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
pill. Reasoning in Display below.
29. ~~**Put the Fedora at the right-hand end of the phase row**~~ — done 2026-08-30 in v0.7.9. It
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
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
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
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
@@ -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
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
+16 -1
View File
@@ -35,6 +35,7 @@ import { legalActions } from '../engine/legal.ts';
import { createGame } from '../engine/setup.ts';
import type { Facility, GameConfig, GameState } from '../engine/state.ts';
import { actingPlayer } from '../engine/state.ts';
import { reasonSentence } from '../web/panels.ts';
import { developerBot, lastChoiceReason } from './bot.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
@@ -148,12 +149,26 @@ export function record(seed: number, length: GameLength, maxSteps = 100_000): Re
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 last = frames[frames.length - 1];
const why = o && last ? reasonSentence(last, o, last.day).replace(/<[^>]+>/g, '') : '';
return {
seed,
length,
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;
phaseKey: string;
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('');
// 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 asked = f.awaiting
? ` <span class="tc-asks">${esc(f.awaiting.asks)} · ${esc(f.awaiting.train)}</span>`
: '';
const fedora =
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>` +
`<span class="dim">${esc(f.clock)}</span></div>` +
`<div class="tc-now">phase <b>${esc(f.phase)}</b></div>` +
`<div class="tc-who">waiting on <b>${esc(who)}</b></div>` +
fedora +
`<ol class="tc-phases">${chips}</ol>`
`<div class="tc-who">waiting on <b>${esc(who)}</b>${asked}</div>` +
// THE FEDORA RIDES AT THE END OF THE PHASE ROW (`TODO.md` #29, Jesse). It sat on its own line
// 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
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-asks{color:#a99ac4;font-style:italic}
.tc-who b{color:#b98cf0;background:rgba(150,110,230,.16);border:1px solid #8b6ad0;
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
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. */
.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}
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;
+28 -2
View File
@@ -47,7 +47,7 @@ import {
} from '../engine/content.ts';
import type { Intent } from '../engine/intents.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 { Port } 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.
*/
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;
/**
* 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;
revenue: number;
/**
@@ -1342,7 +1356,19 @@ export function snapshot(
clock: clockTime(s.clock.stage),
phase: phaseLabel(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,
revenue: s.players[viewer]?.revenue ?? 0,
lines,
+18 -25
View File
@@ -256,25 +256,21 @@ export function runLobby(handlers: LobbyHandlers, resume?: { token: string; game
else if (type === 'custom') type = base;
for (const r of typeRadios()) r.checked = r.value === type;
const scoring = preset(base).scoring;
const note = $('lb-type-note');
if (type === 'custom') {
note.textContent =
`${gameTypeLabel('custom', scoring)} · ${differing.length} ` +
`${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.
$<HTMLDetailsElement>('lb-settings').open = true;
} else {
note.textContent = preset(type as PresetName).blurb;
note.className = 'ng-note';
}
/**
* NO SENTENCE UNDER THE RADIOS since 2026-08-30 — it restated the type just chosen to the person
* who had just chosen it, and the row is already labelled and already carries its own
* description (Jesse: "There's no need to repeat it below"). `form.mark` still puts a hint on
* each row that actually differs, which is where a Custom game's differences can be acted on.
*/
// A Custom game is nobody's default: open the block that says how it differs.
if (type === 'custom') $<HTMLDetailsElement>('lb-settings').open = true;
}
for (const r of typeRadios()) {
// 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.
if (r.value === 'solitaire') markUnavailable(r, 'dealt with the New game button, not here');
// deal it with. Dimmed and left to speak for itself: the heading says "Game type
// (multi-player)", which is the explanation (Jesse, 2026-08-30).
if (r.value === 'solitaire') markUnavailable(r);
r.onchange = () => {
if (!r.checked) return;
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`
* 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;
const row = radio.closest('label');
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);
radio.closest('label')?.classList.add('disabled');
}
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';
else if (type === 'custom') type = base;
for (const r of typeRadios()) r.checked = r.value === type;
const note = field<HTMLElement>('type-note');
note.textContent =
type === 'custom'
? `${gameTypeLabel('custom', preset(base).scoring)} · ${differing.length} ` +
`${differing.length === 1 ? 'setting differs' : 'settings differ'} from ${preset(base).label}.`
: preset(type as PresetName).blurb;
/**
* NO SENTENCE UNDER THE RADIOS. It restated the type just chosen — the row is already labelled
* and already carries its own one-line description — so it was the choice read back to the
* person who had just made it (Jesse, 2026-08-30: "It's obvious from what they selected above
* what they're playing. There's no need to repeat it below.").
*
* 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 {
@@ -2043,19 +2047,21 @@ function wireGameTypeBlock(prefix: string, root: ParentNode): WiredGameType {
}
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
// the click (Jesse, 2026-08-23 — a disabled radio that looks enabled reads as a broken one).
/**
* Nothing here can deal a multiplayer game: a `LocalSession` runs the engine in this browser and
* 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') {
r.disabled = true;
const row = r.closest('label');
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.closest('label')?.classList.add('disabled');
}
r.onchange = () => {
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
* 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.
*
* 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);
switch (o.reason) {
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. */
.ng-radio.disabled{opacity:.45;cursor:not-allowed}
.ng-radio.disabled:hover{background:none}
.lb-why{color:#e0b060;font-size:11px}
#lobby h2,#solitairesetup h2{margin-top:0}
#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)}
@@ -414,12 +413,13 @@ ul.blocked li{padding:2px 0}
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
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
bot. Pick the size of the table now; it cannot change once the game is created.</p>
<p class="ng-note">Every chair must be filled before the game can start. For solitaire,
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 class="lb-col">
<h3>Game type</h3>
<h3>Game type (multi-player)</h3>
<div class="set-row" id="lb-type-row">
<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>
@@ -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>
</div>
<p class="ng-note" id="lb-type-note"></p>
</div>
<!-- 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>
<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
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>
<div class="set-groups">
@@ -646,10 +646,11 @@ ul.blocked li{padding:2px 0}
</div>
<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>
<p class="ng-note">Every chair has to be taken before the game can start — here that is the one
chair, and it is yours. To play with other people, use the Multiplayer door instead.</p>
<p class="ng-note">Every chair must be filled before the game can start. For solitaire,
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">
<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>
@@ -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>
</div>
<p class="ng-note" id="ss-type-note"></p>
<details id="ss-settings" open>
<summary>Game settings</summary>
<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
to it.</p>
Changing any of them selects <b>Custom</b>, which keeps the scoring of the type you
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-group">
<h3>Starting hand</h3>
<p class="ng-note">What you are dealt before the first turn. The hand limit is three either
way — deal six and the first turn is spent choosing which of them to keep.</p>
<p class="ng-note">What each player is dealt before the first turn. The hand limit is three
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">
<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>
@@ -732,7 +734,8 @@ ul.blocked li{padding:2px 0}
<div class="set-group">
<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">
<label class="ng-gate"><input type="checkbox" id="ss-minrev-on" checked>
<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');
});
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 () => {