From a70b7f88f3433ec4a8fc27f955865a632763c4f4 Mon Sep 17 00:00:00 2001
From: "Jesse.Markowitz"
Date: Sun, 30 Aug 2026 09:01:19 -0400
Subject: [PATCH] Say who the game is waiting on, and move the Fedora; replay
in words
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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
Claude-Session: https://claude.ai/code/session_01AdG46Ja2PEDBkpqiDazMoX
---
CHANGELOG.md | 66 +++++++++++++++++++++++++++++++++++++++++-
TODO.md | 23 +++++++++++++--
src/sim/replay.ts | 17 ++++++++++-
src/sim/turnchart.ts | 34 ++++++++++++++++++----
src/sim/view.ts | 30 +++++++++++++++++--
src/web/lobby.ts | 43 ++++++++++++---------------
src/web/main.ts | 40 ++++++++++++++-----------
src/web/panels.ts | 7 ++++-
src/web/play.html | 33 +++++++++++----------
test/replay.test.ts | 23 ++++++++++++++-
test/web.test.ts | 69 +++++++++++++++++++++++++++++++++++++-------
11 files changed, 304 insertions(+), 81 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a8d59e7..a0c3cd8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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 `
` 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.
---
diff --git a/TODO.md b/TODO.md
index a0c06c9..4b06fd2 100644
--- a/TODO.md
+++ b/TODO.md
@@ -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
diff --git a/src/sim/replay.ts b/src/sim/replay.ts
index 748d913..32225d6 100644
--- a/src/sim/replay.ts
+++ b/src/sim/replay.ts
@@ -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 `
` 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',
};
}
diff --git a/src/sim/turnchart.ts b/src/sim/turnchart.ts
index 5c637c7..e7e1cd7 100644
--- a/src/sim/turnchart.ts
+++ b/src/sim/turnchart.ts
@@ -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
+ ? ` ${esc(f.awaiting.asks)} · ${esc(f.awaiting.train)}`
+ : '';
const fedora =
superName === null
? ''
@@ -105,9 +120,12 @@ export function turnChartHtml(f: TurnChartFrame, actorName: string | null, super
`
Day ${f.day}Stage ${f.stage} of 12` +
`${esc(f.clock)}
` +
`
phase ${esc(f.phase)}
` +
- `
waiting on ${esc(who)}
` +
- fedora +
- `${chips}`
+ `
waiting on ${esc(who)}${asked}
` +
+ // 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.
+ `
${chips}${fedora}
`
);
}
@@ -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;
diff --git a/src/sim/view.ts b/src/sim/view.ts
index e2cffb1..755ce68 100644
--- a/src/sim/view.ts
+++ b/src/sim/view.ts
@@ -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,
diff --git a/src/web/lobby.ts b/src/web/lobby.ts
index d9f0661..5536548 100644
--- a/src/web/lobby.ts
+++ b/src/web/lobby.ts
@@ -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.
- $('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') $('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 {
diff --git a/src/web/main.ts b/src/web/main.ts
index d3d361e..66a0234 100644
--- a/src/web/main.ts
+++ b/src/web/main.ts
@@ -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('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;
diff --git a/src/web/panels.ts b/src/web/panels.ts
index 753489d..aeb6680 100644
--- a/src/web/panels.ts
+++ b/src/web/panels.ts
@@ -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, day: number): string {
+export function reasonSentence(f: Frame, o: NonNullable, day: number): string {
const combined = f.players.reduce((n, p) => n + p.revenue, 0);
switch (o.reason) {
case 'daysElapsed':
diff --git a/src/web/play.html b/src/web/play.html
index 21589ce..d4281e2 100644
--- a/src/web/play.html
+++ b/src/web/play.html
@@ -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.
-
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.
+
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.
-
Game type
+
Game type (multi-player)
@@ -433,7 +433,7 @@ ul.blocked li{padding:2px 0}
Custom Whatever you set below. Selected for you the moment you change a rule; it is scored as the type you started from.