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:
co-authored by
Claude Sonnet 5
parent
131538dc7c
commit
a70b7f88f3
+65
-1
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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’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’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
@@ -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
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user