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