Compare commits
3
Commits
31b942cc38
..
v0.7.9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6058f6c17e | ||
|
|
e62ea54259 | ||
|
|
d267f89a82 |
+152
-1
@@ -254,6 +254,157 @@ and the line reads "resumed" when it is.
|
||||
Both halves are pinned, including that a brand-new game does *not* claim to be a resume — an
|
||||
announcement that fires either way says nothing.
|
||||
|
||||
### The screen does what you tell it — three items off `TODO.md`
|
||||
|
||||
Reviewed with Jesse 2026-08-30 out of the Display section. A fourth, #17 (hiding the Division map),
|
||||
was **declined** in the same pass: its premise died with Gitea#18 and nobody had connected the two.
|
||||
The map used to grow a row at a time and was worth folding away at three or four seats; a single row
|
||||
is `boardH = PAD * 2 + CH + 30` — 150px, fixed, at every seat count — and that is not worth a
|
||||
control, three states and a persisted preference.
|
||||
|
||||
#### The Office Area's auto-hide could not reach every state (#16)
|
||||
|
||||
One button cycling `auto -> pinned -> auto`, where the pin it reached was
|
||||
`open ? 'closed' : 'open'` — and `open` is what auto is doing **at that moment**,
|
||||
`FOCUS_PHASES.has(f.phaseKey)`. So which pin a press offered depended on the phase: "always hidden"
|
||||
during Local Operations and Cargo, "always showing" everywhere else. Getting from one pin to the
|
||||
other meant clicking back to auto, waiting for the phase to turn over, and clicking again. That is
|
||||
why it never read as a setting — it was not one.
|
||||
|
||||
Three controls now, one per mode, and every mode is one press from every other. The labels still say
|
||||
what pressing **does** rather than what the panel is doing, which was an earlier deliberate fix; what
|
||||
the cycle could not do was report the state it was in, and `aria-pressed` on the lit button carries
|
||||
that instead of the label.
|
||||
|
||||
**Addressed by id (`#dm-auto`/`#dm-open`/`#dm-closed`) rather than by querying the container's
|
||||
children**, and that is a testability decision rather than a style one. The page never writes this
|
||||
markup, so a child query finds nothing in the stubbed DOM the web suite runs against — the control
|
||||
would have shipped green and completely unexercised. The test that now pins it presses
|
||||
always-show → always-hide directly, which is precisely the transition the cycle could not make.
|
||||
|
||||
#### The history reads newest first (#23)
|
||||
|
||||
Jesse: "it should be reversed so the top line is the most recent and the further down you go, the
|
||||
older the entry." The panel ran oldest-first and scrolled itself to the bottom, so the thing that had
|
||||
just happened was the one line you had to go and find.
|
||||
|
||||
**The phase headings now trail their lines, and that is accepted rather than overlooked.** A
|
||||
`t-phase` line reads forwards — it introduces what follows it — so reversing puts each one below the
|
||||
events it announced. Jesse ruled on it directly: "stage changes will be beneath (prior to / older
|
||||
than) the following events. That is OK." Reading down the panel is reading backwards in time, and a
|
||||
heading under its own lines is what backwards looks like. Grouping by phase and reversing the groups
|
||||
was the alternative and was declined as more machinery than the complaint needs. The
|
||||
"— the game began —" marker moves by the same logic: it is the oldest thing on screen, so it goes
|
||||
last.
|
||||
|
||||
`replays.ts` keeps its own oldest-first log deliberately. It is paired with a frame stepper, where
|
||||
"what just happened" is the step you have this moment clicked, so newest-first would fight the
|
||||
stepping rather than help it.
|
||||
|
||||
#### The settings moved into a card, and the collision counts came out of hiding (#28)
|
||||
|
||||
Jesse, 2026-08-23: "the game-specific information in the very top line should probably be a card like
|
||||
Facilities, timetable or blocked… we can give complete information about all the game options and not
|
||||
take up valuable real estate at the top of the screen." And on when it is read: "To go, 'Oh wait, what
|
||||
did we set that to?'"
|
||||
|
||||
The top line carried six things and now carries four: **Revenue, the objective, the collision counts,
|
||||
and the game code**. The seed or seat, the game type and the abbreviated house rules moved into a
|
||||
**This Game** card at the foot of the right-hand column, folded by default and persisted with the
|
||||
other display preferences.
|
||||
|
||||
**Nothing new travels for it.** `configFromFrame` already turns the Frame's copy of the config back
|
||||
into a `GameConfig`, and `rulesListHtml` is the renderer the lobby's join preview and seating screen
|
||||
already draw — so what a player agreed to before the deal and what they read mid-game come from one
|
||||
implementation and cannot drift. The card adds only the half `rulesListHtml` has no notion of: which
|
||||
seed or seat this is, and what the game is called.
|
||||
|
||||
**The collision counts are new on the board, not merely moved.** The Frame has carried
|
||||
`collisionsToday` and `collisionsTotal` since v0.7.0 and nothing drew them, so the one victory
|
||||
condition that ends a game EARLY ran invisibly — which v0.7.9 made reachable in solitaire too, and
|
||||
that is what made them worth having. They stay on the top line while the limits go in the card,
|
||||
because the two are different kinds of thing: a limit is a setting agreed to once, "2 of 3 today" is
|
||||
a number that changes how you play the next Stage. A limit of `0` is off, and an off half is left out
|
||||
rather than shown as "1 of 0"; with both off the chip is empty and collapses.
|
||||
|
||||
In solitaire the game-code span is empty, so its tooltip — which still carries the type, the blurb and
|
||||
the victory conditions — is unreachable. Not a hole: the card's summary line is on screen whether the
|
||||
card is open or shut and opens with the type, "Solitaire · 5 Days · floor 15 · 3 cards · 4/2/1".
|
||||
|
||||
**One gap in the test stubs was closed to get here.** None of the five element factories in the web
|
||||
suite had `setAttribute`, so the first render threw `b.setAttribute is not a function` — meaning any
|
||||
control that reports its state through ARIA could not be tested at all. They carry an attribute bag
|
||||
now, and the segmented control's test reads `aria-pressed` through it.
|
||||
|
||||
**What is verified and what is not.** The logic is covered by three new tests and the two that used to
|
||||
read `#houserules`/`#gametype` were rewritten to open the card rather than deleted. **The layout is
|
||||
not verified** — there is no browser on this box, so nothing has confirmed the segmented control, the
|
||||
card, or the reversed panel actually look right on screen. That wants a play session.
|
||||
|
||||
### Four things the game counted and never said
|
||||
|
||||
Prompted by Jesse asking the general question after two separate v0.7.9 fixes turned out to have the
|
||||
same shape — `actingPlayer` existed and the Frame threw it away, and `collisionsToday`/`collisionsTotal`
|
||||
rode the Frame for three releases with nothing drawing them. So: **what else is being computed,
|
||||
serialised and sent to nobody?**
|
||||
|
||||
Audited rather than guessed. Every one of `Frame`'s 59 top-level fields grepped for a read across the
|
||||
seven renderers, then the same for `Tally`'s 26 members. **55 of 59 are read.** The four that are not:
|
||||
|
||||
- **`tally.unloadsBegun`** — and this one was visible rather than merely unused. §9.1 makes loading
|
||||
and unloading the same shape, begun then carried through, and the results screen printed "Loads
|
||||
still in the pipeline" for one side and nothing for the other. It reported half of a symmetric
|
||||
mechanism. There is an "Unloads still in the pipeline" line now.
|
||||
- **`tally.cardsDiscarded`** — counted by the engine, listed beside "Cards drawn" and "Cards played"
|
||||
without it. Gitea#9 made throwing a Timetabled train away a legal, deliberate move, so a discard is
|
||||
a player CHOICE the game counted and never reported. It reports it now.
|
||||
- **`viewerSeat`** and **`overHandLimit`** — deferred by Jesse. The second is the fullest version of
|
||||
the shape: the engine computes it, `view.ts` puts it on the Frame, `session.ts` declares it on the
|
||||
`Session` interface and implements it twice, and the only caller in the repo is its own test. Four
|
||||
layers of plumbing with no consumer. Left alone for now; the decision when it comes is
|
||||
delete-or-document, not a patch.
|
||||
|
||||
**Fixing the two turned up a third thing worth knowing.** `resultsHtml` draws
|
||||
`tallyHtml(report?.tally ?? f.tally)`, and `report` is `f.official`, which the engine writes the
|
||||
moment any game ends — so a finished game reports the tally frozen at the official ending, not the
|
||||
live one. The first attempt at a test overrode `f.tally` alone, changed nothing on screen, and failed
|
||||
for a reason that had nothing to do with the fix.
|
||||
|
||||
### A shouted keyword is not a sentence
|
||||
|
||||
`EXTRA X18 started…`, attributed to a player, rendered as `Player Solitaire eXTRA X18 started…`. The
|
||||
same happened to `TRAIN 1 MADE UP` and `COLLISION`, which are shouted deliberately.
|
||||
|
||||
`record` folds a narration's opening word into the middle of a sentence — "Chose to draw" has to read
|
||||
"Player Bob chose to draw" — and did it with a flat `charAt(0).toLowerCase()`. It now folds **only a
|
||||
sentence-cased word**: `^[A-Z][a-z]`, a capital followed by a lower-case letter, which is an ordinary
|
||||
word capitalised because it began a sentence and nothing else is. That also leaves `X22 Pee-Dee`
|
||||
alone, which a naive "is the first letter uppercase?" test gets wrong, because `'2'.toUpperCase()`
|
||||
is `'2'`.
|
||||
|
||||
**It had been filed under Play Balance**, where it has no business being — which is how it survived a
|
||||
session that had explicitly ruled balance work out of scope. Found by reading the section it did not
|
||||
belong to.
|
||||
|
||||
### A replayed save now narrates what the live game narrated
|
||||
|
||||
`fromSave`'s loop called `record(game, result.events)` with no `actor`, so every restored save, every
|
||||
undo (which rebuilds through `fromSave`) and the replay viewer stripped the "Player X" prefix off
|
||||
every attributed line. Live play attributes — `submit` passes `actor` — and so does multiplayer's
|
||||
replay; this was the one path of the three that did not, which meant the same moves were described in
|
||||
different words from the game that produced them. The fix is one argument, with `actor` already
|
||||
computed on the line above.
|
||||
|
||||
**Why it survived two years of green tests, which is the part worth keeping.** Nothing ever compared a
|
||||
`fromSave`-built log against a LIVE-played one. The single log-comparing test compares `undo`'s
|
||||
rebuilt log against another `fromSave`-built log — and since `undo` itself rebuilds through
|
||||
`fromSave`, the missing attribution cancelled out on both sides. The whole suite stayed green with the
|
||||
bug in place, and stayed green after the fix too.
|
||||
|
||||
The new test plays a game, saves it, restores it, and asserts the two logs are identical. That is the
|
||||
**missing direction**, not a new requirement. All three of this batch's fixes were confirmed to fail
|
||||
with the fix reverted before being called done — the discipline the v0.7.5→v0.7.8 sequence bought.
|
||||
|
||||
### Three wording and layout fixes
|
||||
|
||||
- **The collision entries** on all three screens now read "The game ends immediately and results in
|
||||
@@ -268,7 +419,7 @@ announcement that fires either way says nothing.
|
||||
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".
|
||||
|
||||
878 tests pass, eleven of them new; one existing test asserted the opposite of the collision ruling
|
||||
884 tests pass, seventeen of them new; one existing test asserted the opposite of the collision ruling
|
||||
above and says so where it was reversed.
|
||||
|
||||
---
|
||||
|
||||
+40
-9
@@ -1122,6 +1122,24 @@ export function view(game: Game, seat: PlayerIndex = 0): Frame {
|
||||
return snapshot(game.state, [], null, null, null, false, seat);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold a narration's opening word into the middle of a sentence — "Chose to draw" after a name has
|
||||
* to read "Player Bob chose to draw".
|
||||
*
|
||||
* ONLY A SENTENCE-CASED WORD, which is the whole point. It used to be a flat
|
||||
* `text.charAt(0).toLowerCase()`, so every line opening with an all-caps keyword came out mangled:
|
||||
* `EXTRA X18 started…` rendered as `Player Solitaire eXTRA X18 started…`, and the same happened to
|
||||
* `TRAIN 1 MADE UP` and `COLLISION`. Those words are shouted deliberately.
|
||||
*
|
||||
* `^[A-Z][a-z]` is the test — a capital followed by a lower-case letter is an ordinary word that was
|
||||
* capitalised because it began a sentence, and nothing else is. It leaves all-caps keywords alone,
|
||||
* and it also leaves alone a word whose second character is a digit or a hyphen (`X22 Pee-Dee`),
|
||||
* which a naive "is it uppercase?" check would get wrong because `'2'.toUpperCase() === '2'`.
|
||||
*/
|
||||
function uncapitalise(text: string): string {
|
||||
return /^[A-Z][a-z]/.test(text) ? text.charAt(0).toLowerCase() + text.slice(1) : text;
|
||||
}
|
||||
|
||||
function record(game: Game, events: GameEvent[], actor: PlayerIndex | null = null): void {
|
||||
const who = actor === null ? null : (game.state.players[actor]?.name ?? null);
|
||||
for (const e of events) {
|
||||
@@ -1134,7 +1152,7 @@ function record(game: Game, events: GameEvent[], actor: PlayerIndex | null = nul
|
||||
// "Chose to DRAW a card" does not say WHO, which is unreadable the moment there is more than
|
||||
// one seat. Only events the player caused are attributed; the Division running itself is not.
|
||||
const mine = who !== null && 'player' in e;
|
||||
const text = mine ? `Player ${who} ${n.text.charAt(0).toLowerCase()}${n.text.slice(1)}` : n.text;
|
||||
const text = mine ? `Player ${who} ${uncapitalise(n.text)}` : n.text;
|
||||
game.log.push({ text, tone: mine ? 'act' : n.tone });
|
||||
|
||||
}
|
||||
@@ -1235,7 +1253,18 @@ export function fromSave(save: Save, config: GameConfig = SOLO_CONFIG): Game {
|
||||
const result = applyIntent(game.state, actor, intent);
|
||||
if (!result.ok) break;
|
||||
game.history.push(intent);
|
||||
record(game, result.events);
|
||||
/**
|
||||
* `actor` IS PASSED HERE, so a replayed game narrates exactly as the live one did.
|
||||
*
|
||||
* It was omitted, and the omission was invisible in solitaire for a reason worth keeping: the
|
||||
* only test that compares logs ("leaves nothing in the log describing a move that was taken
|
||||
* back") compares one `fromSave`-built log against ANOTHER, so the missing attribution cancelled
|
||||
* out on both sides. Live play attributes (`submit` passes `actor`) and so does multiplayer's
|
||||
* replay (`fromMultiplayerSave`) — this was the one path of the three that did not, which meant
|
||||
* a restored save, an undone game (undo rebuilds through here) and the replay viewer all
|
||||
* described the same moves in different words from the game that produced them.
|
||||
*/
|
||||
record(game, result.events, actor);
|
||||
drain(game);
|
||||
}
|
||||
return game;
|
||||
@@ -1249,16 +1278,18 @@ export function fromSave(save: Save, config: GameConfig = SOLO_CONFIG): Game {
|
||||
* this function only ever reconstructs from history that is already known to have been recorded
|
||||
* under the currently-running rules.
|
||||
*
|
||||
* UNLIKE `fromSave`'s loop, this passes `actor` to `record()` (matching `submit`'s own call,
|
||||
* LIKE `fromSave`'s loop, this passes `actor` to `record()` (matching `submit`'s own call,
|
||||
* `game.ts` above) — found while testing Phase 3's resume path: without it, every replayed line loses
|
||||
* its "Player X" attribution and reads as anonymous "Chose to..." narration, which `record`'s own
|
||||
* comment calls "unreadable the moment there is more than one seat" — exactly the multiplayer case a
|
||||
* resumed game hits every time. `fromSave` has the same gap (it predates multiplayer and nothing ever
|
||||
* compares its output against a LIVE-played log, so it has gone unnoticed — `undo`'s rebuilt game is
|
||||
* itself `fromSave`-built, so `test/web.test.ts`'s replay-fidelity test only ever compares one
|
||||
* unattributed replay against another). Flagged in `TODO.md` rather than fixed there in this pass —
|
||||
* out of scope for Phase 3 and used far more widely, so worth its own careful look rather than a
|
||||
* touch-in-passing.
|
||||
* resumed game hits every time.
|
||||
*
|
||||
* `fromSave` HAD THE SAME GAP AND NO LONGER DOES (fixed 2026-08-30). It predated multiplayer, and
|
||||
* nothing ever compared its output against a LIVE-played log: `undo`'s rebuilt game is itself
|
||||
* `fromSave`-built, so `test/web.test.ts`'s replay-fidelity test only ever compared one unattributed
|
||||
* replay against another and the gap cancelled out on both sides. The test that now pins it plays a
|
||||
* game live, restores it from its own save, and asserts the two logs are identical — which is the
|
||||
* comparison that had been missing rather than a new requirement.
|
||||
*/
|
||||
/**
|
||||
* Why the intent a replay stopped at is reported rather than swallowed.
|
||||
|
||||
+168
-41
@@ -36,7 +36,7 @@ import {
|
||||
settingsOf,
|
||||
} from './presets.ts';
|
||||
import type { GameType, PresetName } from './presets.ts';
|
||||
import { settingsForm } from './settings-form.ts';
|
||||
import { rulesListHtml, settingsForm } from './settings-form.ts';
|
||||
import type { SettingsForm } from './settings-form.ts';
|
||||
|
||||
const SAVE_KEY = 'station-master.save.v1';
|
||||
@@ -62,9 +62,14 @@ type Settings = {
|
||||
districtMode: 'auto' | 'open' | 'closed';
|
||||
soundOn: boolean;
|
||||
zoom: number;
|
||||
/**
|
||||
* Is the This Game card open? (TODO #28.) Folded by default: it answers "what did we set that
|
||||
* to?", which Jesse's own framing says is "not something they're likely to need all the time".
|
||||
*/
|
||||
gameCardOpen: boolean;
|
||||
};
|
||||
|
||||
const DEFAULT_SETTINGS: Settings = { districtMode: 'auto', soundOn: false, zoom: 1 };
|
||||
const DEFAULT_SETTINGS: Settings = { districtMode: 'auto', soundOn: false, zoom: 1, gameCardOpen: false };
|
||||
|
||||
function loadSettings(): Settings {
|
||||
try {
|
||||
@@ -80,6 +85,8 @@ function loadSettings(): Settings {
|
||||
typeof parsed.zoom === 'number' && (ZOOM_LEVELS as readonly number[]).includes(parsed.zoom)
|
||||
? parsed.zoom
|
||||
: DEFAULT_SETTINGS.zoom,
|
||||
gameCardOpen:
|
||||
typeof parsed.gameCardOpen === 'boolean' ? parsed.gameCardOpen : DEFAULT_SETTINGS.gameCardOpen,
|
||||
};
|
||||
} catch {
|
||||
// A full or disabled localStorage must not take the game down with it — same guard as the save.
|
||||
@@ -141,6 +148,7 @@ let pendingAt: string | null = null;
|
||||
* save, for exactly that reason.
|
||||
*/
|
||||
let districtMode: 'auto' | 'open' | 'closed' = settings.districtMode;
|
||||
let gameCardOpen = settings.gameCardOpen;
|
||||
/**
|
||||
* Sound, OFF by default until a player asks for it once — then remembered via `settings`.
|
||||
*
|
||||
@@ -257,17 +265,88 @@ function renderTurnChart(f: Frame): void {
|
||||
* tooltip. Written down at all because a playtest note is worthless without it: "scored 4" means one
|
||||
* thing at 1 Revenue per transit and another at 5.
|
||||
*/
|
||||
function renderHouseRules(rules: HouseRules): void {
|
||||
const { passengerPerCoach: pax, freightPerLoad: frt, trainPerTransit: trn } = rules.revenue;
|
||||
function gameCardSummary(f: Frame): string {
|
||||
const { passengerPerCoach: pax, freightPerLoad: frt, trainPerTransit: trn } = f.houseRules.revenue;
|
||||
const short = { threeRandom: '3 cards', sixRandom: '6 cards', threeTrackThreeOther: '3+3 cards' };
|
||||
const el = $('houserules');
|
||||
el.textContent = `· ${short[rules.startingHand]} · ${pax}/${frt}/${trn}`;
|
||||
const handWords = STARTING_HAND_LABELS.find((o) => o.value === rules.startingHand)?.label ?? '';
|
||||
const config = configFromFrame(f);
|
||||
const type = gameTypeLabel(presetOf(config, f.players.length, f.days), f.mode);
|
||||
const floor = f.minCombinedRevenue === 0 ? 'no floor' : `floor ${f.minCombinedRevenue}`;
|
||||
return `${type} · ${f.days} Days · ${floor} · ${short[f.houseRules.startingHand]} · ${pax}/${frt}/${trn}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* THIS GAME — every setting it was dealt under, in a card rather than along the top line (TODO #28).
|
||||
*
|
||||
* Jesse, 2026-08-23: "the game-specific information in the very top line should probably be a card
|
||||
* like Facilities, timetable or blocked. Off on the side, we can give complete information about all
|
||||
* the game options and not take up valuable real estate at the top of the screen." And on when it is
|
||||
* read: "To go, 'Oh wait, what did we set that to?' They should be able to look that up, but it does
|
||||
* not need to be at the top every moment."
|
||||
*
|
||||
* NOTHING NEW TRAVELS FOR THIS. `configFromFrame` already turns the Frame's copy of the config back
|
||||
* into a `GameConfig`, and `rulesListHtml` is the renderer the lobby's join preview and seating
|
||||
* screen already draw — so what a player agreed to before the deal and what they can read mid-game
|
||||
* come from ONE implementation and cannot drift. The identity block above it is the half
|
||||
* `rulesListHtml` has no notion of: which seed or seat this is, and what the game is called.
|
||||
*
|
||||
* THE SEED IS SOLITAIRE-ONLY, and that is a redaction rule rather than a layout one: it is never
|
||||
* sent to a remote client at all, because it would leak every future shuffle and roll
|
||||
* (`multiplayer.md` §7). `RemoteSession` has no `.seed()` to call. A seated player gets their seat
|
||||
* instead, which is the thing they actually need to know.
|
||||
*/
|
||||
function renderGameCard(f: Frame): void {
|
||||
const sec = $('gamecard');
|
||||
sec.classList.toggle('folded', !gameCardOpen);
|
||||
$('gamecardsummary').textContent = gameCardSummary(f);
|
||||
|
||||
const btn = $('gamecardtoggle');
|
||||
btn.textContent = gameCardOpen ? 'hide' : 'show';
|
||||
btn.onclick = () => {
|
||||
gameCardOpen = !gameCardOpen;
|
||||
saveSettings({ gameCardOpen });
|
||||
render();
|
||||
};
|
||||
if (!gameCardOpen) {
|
||||
// Folded: the body is display:none anyway, and rebuilding it every frame is work nobody sees.
|
||||
$('gamecardbody').innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const config = configFromFrame(f);
|
||||
const players = f.players.length;
|
||||
const type = presetOf(config, players, f.days);
|
||||
const who = isLocal(session)
|
||||
? `<dt>Seed</dt><dd>${esc(String(session.seed()))}</dd>`
|
||||
: `<dt>Seat</dt><dd>${esc(String(seatLabel(session.seat())))}</dd>`;
|
||||
const code = gameCode === '' ? '' : `<dt>Game code</dt><dd>${esc(gameCode)}</dd>`;
|
||||
$('gamecardbody').innerHTML =
|
||||
`<dl>${who}${code}<dt>Type</dt><dd>${esc(gameTypeLabel(type, f.mode))}</dd></dl>` +
|
||||
rulesListHtml(config, players, f.days);
|
||||
}
|
||||
|
||||
/**
|
||||
* THE COLLISION COUNTS, WHICH ARE A LIVE SCORE (TODO #28, Jesse's call 2026-08-30).
|
||||
*
|
||||
* They stay on the top line while the limits themselves move into the card, because the two are
|
||||
* different kinds of thing: `maxCollisionsPerDay` is a setting you agreed to once, and "2 of 3
|
||||
* today" is a number that changes how you play the next Stage. The Frame has carried both counts
|
||||
* since v0.7.0 and nothing drew them, so the one victory condition that ends a game EARLY ran
|
||||
* invisibly — v0.7.9 made it reachable in solitaire too, which is what made this worth having.
|
||||
*
|
||||
* `0` means the limit is off (the engine's convention), and a half that is off is left out rather
|
||||
* than shown as "1 of 0". With both off the chip is empty, and an empty span collapses.
|
||||
*/
|
||||
function renderCollisions(f: Frame): void {
|
||||
const parts: string[] = [];
|
||||
if (f.maxCollisionsPerDay > 0) parts.push(`${f.collisionsToday} of ${f.maxCollisionsPerDay} today`);
|
||||
if (f.maxCollisionsTotal > 0) parts.push(`${f.collisionsTotal} of ${f.maxCollisionsTotal} total`);
|
||||
const el = $('collisions');
|
||||
el.textContent = parts.length === 0 ? '' : `collisions ${parts.join(' · ')}`;
|
||||
el.title =
|
||||
`Opening hand: ${handWords.toLowerCase()}.\n` +
|
||||
`Passenger revenue per coach: ${pax} (paid on boarding and again on detraining).\n` +
|
||||
`Freight revenue per load: ${frt} (paid on loading and again on unloading).\n` +
|
||||
`Train revenue per transit: ${trn} (paid to every player when a train leaves the Division).`;
|
||||
parts.length === 0
|
||||
? ''
|
||||
: 'Reaching either limit ends the game immediately and results in a loss. Both limits are in ' +
|
||||
'the This Game card; these are the running counts.';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -930,21 +1009,32 @@ function renderPresence(f: Frame): void {
|
||||
*/
|
||||
function renderGameIdentity(f: Frame): void {
|
||||
const codeEl = document.getElementById('gamecode');
|
||||
if (codeEl) codeEl.textContent = gameCode === '' ? '' : `game ${gameCode}`;
|
||||
|
||||
const el = document.getElementById('gametype');
|
||||
if (!el) return;
|
||||
if (!codeEl) return;
|
||||
codeEl.textContent = gameCode === '' ? '' : `game ${gameCode}`;
|
||||
/**
|
||||
* THE CODE KEEPS ITS TOOLTIP, AND THE TOOLTIP KEEPS THE RULES. The game type and the house rules
|
||||
* moved into the This Game card (TODO #28), but the code is the thing a player reads out to say
|
||||
* WHICH game they are in — so it is worth being able to hover it and get the whole answer without
|
||||
* opening the card.
|
||||
*
|
||||
* IN SOLITAIRE THERE IS NO CODE, so the span is empty and this tooltip is unreachable. That is not
|
||||
* a hole: the card's summary line is always on screen whether the card is folded or not, and it
|
||||
* opens with the type — "Solitaire · 5 Days · floor 15 · 3 cards · 4/2/1". A lone player has no
|
||||
* game to name to anybody, and the one thing this tooltip adds over that line is the blurb.
|
||||
*/
|
||||
const config = configFromFrame(f);
|
||||
const players = f.players.length;
|
||||
const type = presetOf(config, players, f.days);
|
||||
const near = closestPreset(config, players, f.days);
|
||||
el.textContent = gameTypeLabel(type, f.mode);
|
||||
el.title =
|
||||
const blurb =
|
||||
type === 'custom'
|
||||
? `A custom game, scored as ${preset(near.name).scoring === 'coop' ? 'Co-op' : 'Competitive'}. ` +
|
||||
`${near.differing.length} ${near.differing.length === 1 ? 'setting differs' : 'settings differ'} ` +
|
||||
`from ${preset(near.name).label}.\n\n${rulesSummary(f)}`
|
||||
: `${preset(type).blurb}\n\n${rulesSummary(f)}`;
|
||||
`from ${preset(near.name).label}.`
|
||||
: preset(type).blurb;
|
||||
codeEl.title =
|
||||
`${gameTypeLabel(type, f.mode)}. ${blurb}\n\n${rulesSummary(f)}\n\n` +
|
||||
'The full settings are in the This Game card, at the foot of the right-hand column.';
|
||||
}
|
||||
|
||||
/** The victory conditions in force, spelled out for the header's tooltip. */
|
||||
@@ -1004,11 +1094,9 @@ function render(): void {
|
||||
? `${f.revenue} · Day ${f.day} — ${f.extraDays} beyond the timetable`
|
||||
: `${f.revenue} of ${f.objective.target} · ${left}`;
|
||||
obj.className = 'pace';
|
||||
// The seed is never sent to a remote client at all (it would leak every future shuffle and roll,
|
||||
// `multiplayer.md` §7) — `RemoteSession` has no `.seed()` because there is nothing to return.
|
||||
$('seed').textContent = isLocal(session) ? String(session.seed()) : `Seat ${seatLabel(session.seat())}`;
|
||||
renderCollisions(f);
|
||||
renderGameIdentity(f);
|
||||
renderHouseRules(f.houseRules);
|
||||
renderGameCard(f);
|
||||
|
||||
// -- division
|
||||
$('division').innerHTML = divisionSvg(f.division, {
|
||||
@@ -1288,15 +1376,40 @@ function render(): void {
|
||||
* WHERE THE GAME BEGAN. In a multiplayer game the bots move the instant the host presses Start, so
|
||||
* by the time the board paints the log already has several turns in it and nothing says which of
|
||||
* them are yours to have missed. Only drawn while the whole log is on screen: past sixty lines the
|
||||
* top of the panel is no longer the start of the game, and a marker claiming otherwise would lie.
|
||||
* end of the panel is no longer the start of the game, and a marker claiming otherwise would lie.
|
||||
*/
|
||||
const startMarker =
|
||||
!isLocal(session) && allLines.length === shownLines.length
|
||||
? '<div class="line t-phase">— the game began —</div>'
|
||||
: '';
|
||||
/**
|
||||
* NEWEST FIRST (TODO #23). Jesse, 2026-08-30: "it should be reversed so the top line is the most
|
||||
* recent and the further down you go, the older the entry."
|
||||
*
|
||||
* The panel used to run oldest-first and scroll itself to the bottom, so the thing that had just
|
||||
* happened was the one line you had to go and find. A glance at the top is now always the most
|
||||
* recent thing, and `scrollTop = 0` keeps it there as lines arrive rather than chasing the end.
|
||||
*
|
||||
* THE PHASE HEADINGS NOW TRAIL THEIR LINES, and that is accepted rather than overlooked. A
|
||||
* `t-phase` line reads forwards — it introduces what follows it — so reversing puts each one
|
||||
* BELOW the events it announced. Jesse ruled on it directly: "stage changes will be beneath
|
||||
* (prior to / older than) the following events. That is OK." Reading down the panel is reading
|
||||
* backwards in time, and a heading sitting under its own lines is what backwards looks like.
|
||||
* Grouping by phase and reversing the groups was the alternative, and it was declined as more
|
||||
* machinery than the complaint needs.
|
||||
*
|
||||
* The start marker moves with the same logic: it is the OLDEST thing on screen, so it goes last.
|
||||
*
|
||||
* `replays.ts` keeps its own oldest-first log deliberately — it is paired with a frame stepper,
|
||||
* where "what just happened" is the step you have this moment clicked, so newest-first would
|
||||
* fight the stepping rather than help it.
|
||||
*/
|
||||
log.innerHTML =
|
||||
startMarker + shownLines.map((l) => `<div class="line t-${l.tone}">${esc(l.text)}</div>`).join('');
|
||||
log.scrollTop = log.scrollHeight;
|
||||
shownLines
|
||||
.map((l) => `<div class="line t-${l.tone}">${esc(l.text)}</div>`)
|
||||
.reverse()
|
||||
.join('') + startMarker;
|
||||
log.scrollTop = 0;
|
||||
|
||||
/**
|
||||
* SAY WHEN THE PHASE TURNS OVER.
|
||||
@@ -1408,21 +1521,35 @@ function renderDistrict(f: Frame): void {
|
||||
`${f.cells.length} cards · ${f.facilities.length} facilities · ${cars} cars standing` +
|
||||
(crew > 0 ? ` · ${crew} crew on the board` : '');
|
||||
|
||||
// Say what pressing it DOES, not what the panel is currently doing. "auto · folded" reads as a
|
||||
// status line and was missed entirely; "always show" is an instruction.
|
||||
const btn = $('districttoggle');
|
||||
btn.textContent =
|
||||
districtMode === 'auto'
|
||||
? (open ? 'auto-hide: on — click to keep open' : 'auto-hide: on — click to show')
|
||||
: districtMode === 'open'
|
||||
? 'always showing — click for auto-hide'
|
||||
: 'always hidden — click for auto-hide';
|
||||
btn.onclick = () => {
|
||||
// auto -> pin it to the opposite of what auto is doing -> back to auto.
|
||||
districtMode = districtMode === 'auto' ? (open ? 'closed' : 'open') : 'auto';
|
||||
saveSettings({ districtMode });
|
||||
render();
|
||||
};
|
||||
/**
|
||||
* THREE CONTROLS, ONE PER MODE (TODO #16) — not one control that cycles.
|
||||
*
|
||||
* The cycle was `auto -> (open ? 'closed' : 'open') -> auto`, where `open` is what auto is doing
|
||||
* AT THAT MOMENT — `FOCUS_PHASES.has(f.phaseKey)`. So which pin a press reached depended on the
|
||||
* phase: during Local Operations or Cargo it offered "always hidden", and in every other phase
|
||||
* "always showing". Getting from one pin to the other meant clicking back to auto, waiting for
|
||||
* the phase to turn over, and clicking again — which is why it never read as a setting.
|
||||
*
|
||||
* The labels still say what pressing DOES rather than what the panel is doing. That was a
|
||||
* deliberate earlier fix ("auto · folded" read as a status line and was missed entirely) and it
|
||||
* survives the change; what the CYCLE could not do was be honest about the state it was in, which
|
||||
* is now carried by `aria-pressed` and the lit button instead of by the label.
|
||||
*/
|
||||
/**
|
||||
* Addressed by id, one lookup each, rather than by querying the container's children. Everything
|
||||
* else on this page is reached with `$('...')`, and it is what makes the control testable at all:
|
||||
* the page never writes this markup, so a child query finds nothing in a stubbed DOM and the
|
||||
* whole control would ship green and unexercised.
|
||||
*/
|
||||
for (const mode of ['auto', 'open', 'closed'] as const) {
|
||||
const b = $(`dm-${mode}`);
|
||||
b.setAttribute('aria-pressed', String(mode === districtMode));
|
||||
b.onclick = () => {
|
||||
districtMode = mode;
|
||||
saveSettings({ districtMode });
|
||||
render();
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -352,7 +352,17 @@ function tallyHtml(t: Frame['tally']): string {
|
||||
};
|
||||
push('Loads made up', t.loadsCompleted);
|
||||
push('Loads broken', t.unloadsCompleted);
|
||||
/**
|
||||
* BOTH HALVES OF THE MEN | AT | WORK PIPELINE, not just the loading one.
|
||||
*
|
||||
* §9.1 makes loading and unloading the same shape — begun, then carried through — and the Tally
|
||||
* has counted both since it was written. The screen reported only the loading side, so a player
|
||||
* with three unloads part-finished at the final whistle was told nothing about them while the
|
||||
* equivalent loads were listed. Found 2026-08-30 auditing which Frame fields nothing reads:
|
||||
* `unloadsBegun` was one of four, and the only one whose absence was visible on screen.
|
||||
*/
|
||||
push('Loads still in the pipeline', t.loadsStarted - t.loadsCompleted);
|
||||
push('Unloads still in the pipeline', t.unloadsBegun - t.unloadsCompleted);
|
||||
push('Passengers boarded', t.passengersBoarded);
|
||||
push('Passengers detrained', t.passengersDetrained);
|
||||
push('Cars coupled', t.carsCoupled);
|
||||
@@ -386,6 +396,10 @@ function tallyHtml(t: Frame['tally']): string {
|
||||
}
|
||||
push('Cards drawn', t.cardsDrawn);
|
||||
push('Cards played', t.cardsPlayed);
|
||||
// Gitea#9 made throwing a Timetabled train away a legal and deliberate move, so a discard is a
|
||||
// CHOICE the player made rather than an accident of the hand limit — and the engine has counted
|
||||
// it all along while the screen listed only draws and plays beside it.
|
||||
push('Cards discarded', t.cardsDiscarded);
|
||||
|
||||
return `<h4 class="res-h">The railroad</h4>${factTable(rows)}`;
|
||||
}
|
||||
|
||||
+45
-10
@@ -215,6 +215,17 @@ button.act{display:inline-block}
|
||||
button.ghost{background:#222831;border:1px solid #4a5361;color:#c6ccd6;font-size:11px;
|
||||
padding:2px 9px;margin-left:10px;text-transform:none;letter-spacing:0;vertical-align:middle}
|
||||
button.ghost:hover{border-color:#4d6fa8;color:var(--fg)}
|
||||
/* A SEGMENTED CONTROL, BECAUSE A CYCLE COULD NOT REACH EVERY STATE (TODO #16).
|
||||
One button that steps auto -> pinned -> auto can only ever offer the pin OPPOSITE to whatever
|
||||
auto is doing at that moment, which depends on the phase — so "always hidden" was unreachable
|
||||
from "always showing" without waiting for the right phase in between. Three controls, one per
|
||||
mode, and the current one is lit. The buttons still say what they DO rather than what the panel
|
||||
is doing, which was the earlier fix and is worth keeping. */
|
||||
.seg{display:inline-flex;margin-left:10px;vertical-align:middle;border-radius:4px;overflow:hidden;
|
||||
border:1px solid #4a5361}
|
||||
.seg button.ghost{margin:0;border:0;border-radius:0;border-left:1px solid #4a5361}
|
||||
.seg button.ghost:first-child{border-left:0}
|
||||
.seg button.ghost[aria-pressed="true"]{background:#2f3a4a;color:var(--fg);font-weight:600}
|
||||
#district.folded #grid{display:none}
|
||||
#district.folded .districtrule{display:none}
|
||||
/* Said once, quietly, beside the thing it governs — a rule a player needs on their first district
|
||||
@@ -224,6 +235,16 @@ button.ghost:hover{border-color:#4d6fa8;color:var(--fg)}
|
||||
.districtrule b{color:#cfd6e0}
|
||||
#district.folded #districtsummary{display:block;padding:2px 0 1px;font-size:12px}
|
||||
#districtsummary{display:none}
|
||||
/* The This Game card folds the same way the district does, and for the same reason: a panel that
|
||||
vanishes entirely reads as broken, so the summary line is what a folded card still says. */
|
||||
#gamecard.folded #gamecardbody{display:none}
|
||||
#gamecard #gamecardsummary{display:none}
|
||||
#gamecard.folded #gamecardsummary{display:block;padding:2px 0 1px;font-size:12px}
|
||||
#gamecardbody dl{display:grid;grid-template-columns:auto 1fr;gap:2px 10px;margin:4px 0 10px}
|
||||
#gamecardbody dt{color:#8b94a3;font-size:11px}
|
||||
#gamecardbody dd{margin:0;font-size:12px;color:#cfd6e0}
|
||||
#gamecardbody dd.changed{color:#f0b64a}
|
||||
#gamecardbody h4{margin:8px 0 0;font-size:11px;text-transform:uppercase;letter-spacing:.06em;color:#8b94a3}
|
||||
/* An action you cannot take yet keeps its place but drops its light — the amber means "press me",
|
||||
so a disabled button must not wear it. */
|
||||
#actions button.blocked,#actions button:disabled{background:#232830;border:1px dashed #4a5361;
|
||||
@@ -816,20 +837,23 @@ ul.blocked li{padding:2px 0}
|
||||
<!-- The objective, and nothing else: score, target, Days left. The "behind the pace" chip and the
|
||||
engine's guess at what your score ought to be were noise on the one line that must not wrap. -->
|
||||
<span id="objective" class="pace">—</span>
|
||||
<span class="dim">seed <span id="seed">—</span></span>
|
||||
<!-- THE COLLISION COUNTS, WHICH ARE A LIVE SCORE AND NOT A SETTING (TODO #28). The Frame has
|
||||
carried `collisionsToday` and `collisionsTotal` since v0.7.0 and nothing on the board drew
|
||||
them, so the one victory condition that can end a game early was invisible while it ran.
|
||||
Empty and collapsed when both limits are 0 — a game that cannot end this way should not be
|
||||
counting towards it. The limits themselves live in the This Game card; this is progress. -->
|
||||
<span class="dim" id="collisions" title=""></span>
|
||||
<!-- THE GAME CODE SURVIVES THE LOBBY. It used to end at `Lobby.Start` — the code was never carried
|
||||
into `LobbyReady` — so a seated player could not say which game they were in, could not match
|
||||
it against the administrator's Games in Progress list, and could not pass it to a latecomer.
|
||||
Empty (and collapsed) in solitaire, where there is no code. -->
|
||||
<span class="dim" id="gamecode" title="The code this game was created under. The administrator's Games in Progress list uses it, and it is how you say which game you mean."></span>
|
||||
<!-- Co-op, Competitive, Cutthroat or Custom, derived from the config the Frame carries
|
||||
(`presets.ts`). A Cutthroat game used to look exactly like a Co-op one from the board. -->
|
||||
<span class="dim" id="gametype" title=""></span>
|
||||
<!-- WHICH RULES THIS GAME IS BEING PLAYED UNDER. The settings are chosen when the game is dealt
|
||||
and then never mentioned again, which makes a playtest note ("scored 4") unreadable a week
|
||||
later: at 0 revenue per transit that is a different game from the same seed at 5. Short enough
|
||||
to keep the header on one line; the tooltip spells it out. -->
|
||||
<span class="dim" id="houserules" title="">—</span>
|
||||
<!-- The seed, the seat, the game type and every house rule MOVED TO THE THIS GAME CARD, 2026-08-30
|
||||
(TODO #28). Jesse: "we can give complete information about all the game options and not take
|
||||
up valuable real estate at the top of the screen… it is not something that they're likely to
|
||||
need all the time." What stays here is what is glanced at every turn — Revenue, the objective,
|
||||
the collision counts — plus the game code, which is identity rather than settings: it is how
|
||||
you say WHICH game you are in, out loud, without opening anything. -->
|
||||
<button id="sound" title="Whistle at the end of each Stage, the crossing bell at the end of each Day, and the conductor when a train is built. Currently synthesised, not recorded.">🔇 muted</button>
|
||||
<!-- BOARD ZOOM. Applies to the Division map and the Office Area grid alike — both already scroll
|
||||
horizontally (`#division`, `#grid`) when they run wide, so this only ever needs to resize the
|
||||
@@ -873,7 +897,7 @@ ul.blocked li{padding:2px 0}
|
||||
<section id="district">
|
||||
<h2>Your Office Area
|
||||
<span class="dim" style="text-transform:none;letter-spacing:0">— hover any card for the full explanation</span>
|
||||
<button id="districttoggle" class="ghost" title="Auto-hide keeps the district open during Local Operations and Cargo — the phases that change it — and folds it otherwise. Click to pin it open or hidden instead.">auto-hide: on</button>
|
||||
<span id="districttoggle" class="seg" role="group" aria-label="When to show your Office Area"><button id="dm-auto" class="ghost" type="button" title="Open during Local Operations and Cargo — the phases that change the district — and folded otherwise.">Auto-hide</button><button id="dm-open" class="ghost" type="button" title="Keep the Office Area open in every phase.">Always show</button><button id="dm-closed" class="ghost" type="button" title="Keep the Office Area folded in every phase. The summary line stays, so it reads as folded rather than missing.">Always hide</button></span>
|
||||
</h2>
|
||||
<div id="districtsummary" class="dim"></div>
|
||||
<!-- THE RULE THAT SHAPES EVERY DISTRICT, said once where the district is.
|
||||
@@ -917,6 +941,17 @@ ul.blocked li{padding:2px 0}
|
||||
</section>
|
||||
<section><h2>Blocked — why nothing is moving</h2><ul class="blocked" id="blocked"></ul></section>
|
||||
<section><h2>Facilities</h2><div id="facs"></div></section>
|
||||
<!-- THIS GAME — the settings it was dealt under, off the top line and out of the way (TODO #28).
|
||||
Last in the column and folded by default because it is looked up, not watched: "Oh wait,
|
||||
what did we set that to?" The body is `rulesListHtml`, the same renderer the join preview
|
||||
and the seating screen draw, so what you agreed to in the lobby and what you can read
|
||||
mid-game cannot drift apart. -->
|
||||
<section id="gamecard" class="folded"><h2>This Game
|
||||
<button id="gamecardtoggle" class="ghost" type="button" title="The seed, the seat, the game type and every rule this game was dealt under.">show</button>
|
||||
</h2>
|
||||
<div id="gamecardsummary" class="dim"></div>
|
||||
<div id="gamecardbody"></div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
|
||||
+247
-6
@@ -1368,6 +1368,55 @@ describe('replays are saves', () => {
|
||||
assert.deepEqual(back.log, fromSave(toSave(back)).log, 'the rebuilt log is not the replayed log');
|
||||
});
|
||||
|
||||
it('replays a save into the same words the live game wrote', () => {
|
||||
/**
|
||||
* THE COMPARISON THAT WAS MISSING. `fromSave`'s loop called `record(game, result.events)` with
|
||||
* no `actor`, so every restored save, every undo (which rebuilds through `fromSave`) and the
|
||||
* replay viewer stripped the "Player X" prefix off every attributed line — describing the same
|
||||
* moves in different words from the game that produced them.
|
||||
*
|
||||
* It went unnoticed because nothing compared a `fromSave`-built log against a LIVE-played one.
|
||||
* The test above compares one rebuilt log against another rebuilt log, so the gap cancelled out
|
||||
* on both sides and stayed green throughout. This plays a game, saves it, restores it, and
|
||||
* asserts the two logs are identical — the direction that catches it.
|
||||
*/
|
||||
const live = newGame(202);
|
||||
for (let i = 0; i < 30; i++) {
|
||||
if (currentActor(live) === null) break;
|
||||
const { options } = actionGroups(live);
|
||||
if (options.length === 0 || !submit(live, options[0]!)) break;
|
||||
}
|
||||
const attributed = live.log.filter((l) => l.text.startsWith('Player '));
|
||||
assert.ok(attributed.length > 0, 'the live game attributed nothing, so this proves nothing');
|
||||
|
||||
assert.deepEqual(
|
||||
fromSave(toSave(live)).log,
|
||||
live.log,
|
||||
'a restored game does not narrate what the live game narrated',
|
||||
);
|
||||
});
|
||||
|
||||
it('does not mangle a narration that opens with a shouted keyword', () => {
|
||||
/**
|
||||
* `record` folds a narration's first word into the middle of a sentence — "Chose to draw" has to
|
||||
* read "Player Bob chose to draw". It used to do that with a flat `charAt(0).toLowerCase()`, so
|
||||
* every line opening with an all-caps keyword came out as `Player Solitaire eXTRA X18 started…`.
|
||||
* The same happened to `TRAIN 1 MADE UP` and `COLLISION`, which are shouted on purpose.
|
||||
*/
|
||||
const game = newGame(202);
|
||||
for (let i = 0; i < 120; i++) {
|
||||
if (currentActor(game) === null) break;
|
||||
const { options } = actionGroups(game);
|
||||
if (options.length === 0 || !submit(game, options[0]!)) break;
|
||||
}
|
||||
const mangled = game.log.filter((l) => /^Player .* [a-z][A-Z]{2}/.test(l.text));
|
||||
assert.deepEqual(mangled, [], 'a shouted keyword was lowercased into the middle of a word');
|
||||
|
||||
// And the ordinary case still folds, or the prefix would read "Player Bob Chose to draw".
|
||||
const folded = game.log.filter((l) => /^Player \S+ [a-z]/.test(l.text));
|
||||
assert.ok(folded.length > 0, 'nothing was folded at all — the prefix is no longer a sentence');
|
||||
});
|
||||
|
||||
it('stays small enough to email', () => {
|
||||
const game = newGame(202);
|
||||
for (let i = 0; i < 400; i++) {
|
||||
@@ -1462,7 +1511,17 @@ describe('the static build', () => {
|
||||
// opens it with `showModal`. Without these the page throws before it draws anything — which
|
||||
// is a page that never starts, exactly what this stub exists to catch.
|
||||
const listeners = new Map<string, ((e?: unknown) => void)[]>();
|
||||
/**
|
||||
* ARIA STATE, WHICH THE PAGE USES TO SAY WHICH MODE IS CURRENT. Added 2026-08-30 with the
|
||||
* Office Area's segmented control (TODO #16): without it every render threw
|
||||
* `b.setAttribute is not a function`, so a stub that cannot model an attribute means any
|
||||
* accessible control ships green and unexercised.
|
||||
*/
|
||||
const attrs: Record<string, string> = {};
|
||||
const node: Record<string, unknown> = {
|
||||
attrs,
|
||||
setAttribute: (k: string, v: string) => void (attrs[k] = v),
|
||||
getAttribute: (k: string) => attrs[k] ?? null,
|
||||
textContent: '', style: {}, dataset: {}, onclick: null, scrollTop: 0, scrollHeight: 0,
|
||||
title: '', returnValue: '', open: false,
|
||||
addEventListener: (type: string, fn: (e?: unknown) => void) =>
|
||||
@@ -1707,7 +1766,17 @@ describe('the static build', () => {
|
||||
let html = '';
|
||||
const listeners = new Map<string, ((e?: unknown) => void)[]>();
|
||||
const ownClasses = new Set<string>();
|
||||
/**
|
||||
* ARIA STATE, WHICH THE PAGE USES TO SAY WHICH MODE IS CURRENT. Added 2026-08-30 with the
|
||||
* Office Area's segmented control (TODO #16): without it every render threw
|
||||
* `b.setAttribute is not a function`, so a stub that cannot model an attribute means any
|
||||
* accessible control ships green and unexercised.
|
||||
*/
|
||||
const attrs: Record<string, string> = {};
|
||||
const node: Record<string, unknown> = {
|
||||
attrs,
|
||||
setAttribute: (k: string, v: string) => void (attrs[k] = v),
|
||||
getAttribute: (k: string) => attrs[k] ?? null,
|
||||
textContent: '', style: {}, dataset: {}, onclick: null, disabled: false,
|
||||
title: '', returnValue: '', open: false,
|
||||
addEventListener: (type: string, fn: (e?: unknown) => void) =>
|
||||
@@ -1812,7 +1881,17 @@ describe('the static build', () => {
|
||||
it('falls back to the defaults when settings are missing or corrupt, rather than throwing', async () => {
|
||||
const els = new Map<string, Record<string, unknown>>();
|
||||
const make = (): Record<string, unknown> => {
|
||||
/**
|
||||
* ARIA STATE, WHICH THE PAGE USES TO SAY WHICH MODE IS CURRENT. Added 2026-08-30 with the
|
||||
* Office Area's segmented control (TODO #16): without it every render threw
|
||||
* `b.setAttribute is not a function`, so a stub that cannot model an attribute means any
|
||||
* accessible control ships green and unexercised.
|
||||
*/
|
||||
const attrs: Record<string, string> = {};
|
||||
const node: Record<string, unknown> = {
|
||||
attrs,
|
||||
setAttribute: (k: string, v: string) => void (attrs[k] = v),
|
||||
getAttribute: (k: string) => attrs[k] ?? null,
|
||||
textContent: '', style: {}, dataset: {}, onclick: null, disabled: false,
|
||||
title: '', returnValue: '', open: false,
|
||||
addEventListener: () => {},
|
||||
@@ -3594,6 +3673,50 @@ describe('the end-of-game results screen (Gitea#16)', () => {
|
||||
assert.ok(html.includes('Trains through the Division'), 'the tally section is missing');
|
||||
});
|
||||
|
||||
it('reports BOTH halves of the work pipeline, and cards discarded', () => {
|
||||
/**
|
||||
* FOUND 2026-08-30 auditing which `Frame` fields nothing reads. Two `Tally` members were
|
||||
* counted by the engine and drawn by nothing:
|
||||
*
|
||||
* - `unloadsBegun`. §9.1 makes loading and unloading the same shape — begun, then carried
|
||||
* through — and the screen printed "Loads still in the pipeline" for one side and nothing for
|
||||
* the other, so it reported half of a symmetric mechanism.
|
||||
* - `cardsDiscarded`. Gitea#9 made throwing a Timetabled train away a legal, deliberate move,
|
||||
* so a discard is a CHOICE, listed beside draws and plays rather than left out of them.
|
||||
*
|
||||
* The tally is overridden rather than played into, because reaching a part-finished unload and
|
||||
* a discard in the same seeded game is incidental to what is being checked here.
|
||||
*/
|
||||
const f = finished();
|
||||
/**
|
||||
* THE OFFICIAL REPORT'S TALLY IS THE ONE DRAWN, not the Frame's — `resultsHtml` renders
|
||||
* `tallyHtml(report?.tally ?? f.tally)` and `report` is `f.official`, which the engine writes
|
||||
* the moment any game ends. Overriding only `f.tally` here changed nothing at all and the test
|
||||
* failed for a reason that had nothing to do with the fix, which is worth pinning in passing:
|
||||
* a finished game reports the numbers frozen at the official ending, not the live ones.
|
||||
*/
|
||||
const withTally = (over: Partial<Frame['tally']>): Frame => ({
|
||||
...f,
|
||||
tally: { ...f.tally, ...over },
|
||||
official: f.official ? { ...f.official, tally: { ...f.official.tally, ...over } } : f.official,
|
||||
});
|
||||
const html = resultsHtml(withTally({ unloadsBegun: 5, unloadsCompleted: 2, cardsDiscarded: 3 }));
|
||||
assert.ok(html.includes('Unloads still in the pipeline'), 'the unloading pipeline is not reported');
|
||||
assert.ok(html.includes('Cards discarded'), 'a deliberate discard is counted and never reported');
|
||||
|
||||
// The in-flight count is begun-minus-completed, matching the loading line beside it.
|
||||
assert.match(
|
||||
html,
|
||||
/Unloads still in the pipeline<\/[a-z]+><[^>]*>3</,
|
||||
'the unloading pipeline does not report begun-minus-completed',
|
||||
);
|
||||
|
||||
// And a game with nothing in flight and nothing discarded says neither — `push` drops a zero.
|
||||
const quiet = resultsHtml(withTally({ unloadsBegun: 2, unloadsCompleted: 2, cardsDiscarded: 0 }));
|
||||
assert.ok(!quiet.includes('Unloads still in the pipeline'), 'an empty pipeline was reported as a fact');
|
||||
assert.ok(!quiet.includes('Cards discarded'), 'a game with no discards claimed to have some');
|
||||
});
|
||||
|
||||
it('names the winner the OFFICIAL result named, not whoever leads now (Gitea#11)', () => {
|
||||
const f = finished({ mode: 'competitive', minCombinedRevenue: 0 }, ['Ada', 'Bo']);
|
||||
// Ada won at the timetable; Bo overtakes during extended play. The frozen result stands.
|
||||
@@ -3792,7 +3915,17 @@ describe('the lobby screen', () => {
|
||||
const make = (id: string): Record<string, unknown> => {
|
||||
const classes = new Set<string>();
|
||||
let html = '';
|
||||
/**
|
||||
* ARIA STATE, WHICH THE PAGE USES TO SAY WHICH MODE IS CURRENT. Added 2026-08-30 with the
|
||||
* Office Area's segmented control (TODO #16): without it every render threw
|
||||
* `b.setAttribute is not a function`, so a stub that cannot model an attribute means any
|
||||
* accessible control ships green and unexercised.
|
||||
*/
|
||||
const attrs: Record<string, string> = {};
|
||||
const node: Record<string, unknown> = {
|
||||
attrs,
|
||||
setAttribute: (k: string, v: string) => void (attrs[k] = v),
|
||||
getAttribute: (k: string) => attrs[k] ?? null,
|
||||
id, value: '', textContent: '', title: '', placeholder: '', className: '',
|
||||
style: {}, dataset: {}, onclick: null, oninput: null, onchange: null,
|
||||
checked: false, disabled: false, hidden: false, open: false, returnValue: '',
|
||||
@@ -4165,7 +4298,17 @@ describe('the solitaire setup screen', () => {
|
||||
const make = (id: string): Record<string, unknown> => {
|
||||
const listeners = new Map<string, (() => void)[]>();
|
||||
let html = '';
|
||||
/**
|
||||
* ARIA STATE, WHICH THE PAGE USES TO SAY WHICH MODE IS CURRENT. Added 2026-08-30 with the
|
||||
* Office Area's segmented control (TODO #16): without it every render threw
|
||||
* `b.setAttribute is not a function`, so a stub that cannot model an attribute means any
|
||||
* accessible control ships green and unexercised.
|
||||
*/
|
||||
const attrs: Record<string, string> = {};
|
||||
const node: Record<string, unknown> = {
|
||||
attrs,
|
||||
setAttribute: (k: string, v: string) => void (attrs[k] = v),
|
||||
getAttribute: (k: string) => attrs[k] ?? null,
|
||||
id, value: '', textContent: '', title: '', returnValue: '', open: false, placeholder: '',
|
||||
style: {}, dataset: {}, onclick: null, oninput: null, onchange: null, scrollTop: 0, scrollHeight: 0,
|
||||
checked: false, disabled: false, hidden: false, className: '',
|
||||
@@ -4403,18 +4546,116 @@ describe('the solitaire setup screen', () => {
|
||||
assert.equal(n.reloads, 1, 'a re-deal at the same settings did nothing at all');
|
||||
});
|
||||
|
||||
it('deals the game the URL describes, and says so in the header', async () => {
|
||||
it('deals the game the URL describes, and says so in the This Game card', async () => {
|
||||
// The other half of the round trip: the dialog wrote those parameters, and this is the page
|
||||
// reading them back. Without this the two halves can drift and each still pass its own test.
|
||||
//
|
||||
// MOVED OFF THE HEADER 2026-08-30 (TODO #28). It used to read `#houserules`, an abbreviation
|
||||
// along the top line. The card is the whole of it now, so this checks both halves: the summary
|
||||
// line a folded card still shows, and the full list behind it.
|
||||
const { els } = await load('?seed=430&hand=sixRandom&passenger=4&freight=2&transit=1');
|
||||
assert.match(String(els.get('houserules')!['textContent']), /6 cards.*4\/2\/1/);
|
||||
assert.match(String(els.get('houserules')!['title']), /six random cards/i);
|
||||
assert.match(
|
||||
String(els.get('gamecardsummary')!['textContent']),
|
||||
/6 cards · 4\/2\/1/,
|
||||
'the folded card does not say what this game was dealt under',
|
||||
);
|
||||
|
||||
// Folded, the body is not built at all — there is no point rendering what display:none hides.
|
||||
assert.equal(String(els.get('gamecardbody')!['innerHTML']), '', 'a folded card built its body anyway');
|
||||
|
||||
(els.get('gamecardtoggle')!['onclick'] as () => void)();
|
||||
const body = String(els.get('gamecardbody')!['innerHTML']);
|
||||
assert.match(body, /Starting hand<\/dt><dd[^>]*>six random/, 'the opened card does not name the opening hand');
|
||||
assert.match(body, /Passenger per coach<\/dt><dd[^>]*>4/, 'the opened card does not carry the revenue rates');
|
||||
assert.match(body, /Seed<\/dt><dd[^>]*>430/, 'the opened card does not say which seed this is');
|
||||
});
|
||||
|
||||
it('names the game type in the header, so a Cutthroat game does not look like a Co-op one', async () => {
|
||||
it('reaches every Office Area mode directly, including the one the cycle could not', async () => {
|
||||
/**
|
||||
* TODO #16, AND THIS IS THE DEFECT ITSELF. The control used to be ONE button stepping
|
||||
* `auto -> (open ? 'closed' : 'open') -> auto`, where `open` is what auto is doing at that
|
||||
* moment — `FOCUS_PHASES.has(f.phaseKey)`. So the pin a press reached depended on the phase,
|
||||
* and going from "always showing" to "always hidden" meant clicking back to auto, waiting for
|
||||
* the phase to turn over, and clicking again. Three controls, one per mode, and every mode is
|
||||
* one press away from every other.
|
||||
*/
|
||||
const { els } = await load('?seed=430');
|
||||
assert.equal(els.get('gametype')!['textContent'], 'Solitaire');
|
||||
assert.match(String(els.get('gametype')!['title']), /Days: 5/, 'the tooltip does not carry the victory conditions');
|
||||
const press = (m: string): void => (els.get(`dm-${m}`)!['onclick'] as () => void)();
|
||||
const pressed = (m: string): string =>
|
||||
String((els.get(`dm-${m}`)!['attrs'] as Record<string, string>)['aria-pressed']);
|
||||
// Which mode is LIT is what this test is about. Whether the panel then folds is `renderDistrict`'s
|
||||
// existing behaviour, covered where the fold rules are — this suite's stub has a no-op classList.
|
||||
|
||||
assert.equal(pressed('auto'), 'true', 'the page did not start on auto');
|
||||
|
||||
press('open');
|
||||
assert.equal(pressed('open'), 'true', 'pressing "always show" did not light it');
|
||||
assert.equal(pressed('auto'), 'false', 'auto stayed lit after pinning the panel open');
|
||||
|
||||
// THE PRESS THE CYCLE COULD NOT MAKE: straight from one pin to the other, in one click, with
|
||||
// no trip through auto and no waiting for a phase.
|
||||
press('closed');
|
||||
assert.equal(pressed('closed'), 'true', 'could not go from always-show to always-hide');
|
||||
assert.equal(pressed('open'), 'false', 'two modes were lit at once');
|
||||
|
||||
press('auto');
|
||||
assert.equal(pressed('auto'), 'true', 'could not get back to auto');
|
||||
assert.equal(pressed('closed'), 'false', 'the previous mode stayed lit');
|
||||
});
|
||||
|
||||
it('puts the newest line at the top of the history', async () => {
|
||||
/**
|
||||
* TODO #23. Jesse, 2026-08-30: "it should be reversed so the top line is the most recent and
|
||||
* the further down you go, the older the entry." The panel used to run oldest-first and scroll
|
||||
* itself to the bottom, so what had just happened was the line you had to go and find.
|
||||
*/
|
||||
const { els } = await load('?seed=430&hand=sixRandom');
|
||||
const lines = [...String(els.get('log')!['innerHTML']).matchAll(/<div class="line[^"]*">([^<]*)<\/div>/g)].map(
|
||||
(m) => m[1]!,
|
||||
);
|
||||
assert.ok(lines.length > 1, 'the log was too short to have an order at all');
|
||||
|
||||
// The deal is the OLDEST thing that has happened, so it must now be LAST rather than first.
|
||||
const dealtAt = lines.findIndex((l) => /dealt|begins|opening/i.test(l));
|
||||
assert.notEqual(dealtAt, -1, 'nothing in the log looks like the opening line');
|
||||
assert.equal(
|
||||
dealtAt,
|
||||
lines.length - 1,
|
||||
`the opening line is at ${dealtAt} of ${lines.length} — the log is still oldest-first`,
|
||||
);
|
||||
assert.equal(els.get('log')!['scrollTop'], 0, 'the panel still scrolls itself to the bottom');
|
||||
});
|
||||
|
||||
it('shows the collision counts only while a collision limit is switched on', async () => {
|
||||
/**
|
||||
* TODO #28. The limits moved into the This Game card; the running counts stay on the top line,
|
||||
* because a setting agreed to once and a number that changes how you play the next Stage are
|
||||
* different kinds of thing. `0` means the limit is off, and a half that is off is left out
|
||||
* rather than shown as "1 of 0".
|
||||
*/
|
||||
const on = await load('?seed=430&colday=3&coltotal=5');
|
||||
assert.match(String(on.els.get('collisions')!['textContent']), /0 of 3 today · 0 of 5 total/);
|
||||
|
||||
const dayOnly = await load('?seed=430&colday=3&coltotal=0');
|
||||
const text = String(dayOnly.els.get('collisions')!['textContent']);
|
||||
assert.match(text, /0 of 3 today/);
|
||||
assert.doesNotMatch(text, /total/, 'a switched-off limit was counted towards anyway');
|
||||
|
||||
const off = await load('?seed=430&colday=0&coltotal=0');
|
||||
assert.equal(off.els.get('collisions')!['textContent'], '', 'a game that cannot end this way still counted');
|
||||
});
|
||||
|
||||
it('names the game type in the card, so a Cutthroat game does not look like a Co-op one', async () => {
|
||||
const { els } = await load('?seed=430');
|
||||
// The summary line carries it whether the card is open or shut — which is what replaces the
|
||||
// header's `#gametype`, and is why solitaire losing the (empty) game-code tooltip costs nothing.
|
||||
assert.match(String(els.get('gamecardsummary')!['textContent']), /^Solitaire · 5 Days/);
|
||||
|
||||
(els.get('gamecardtoggle')!['onclick'] as () => void)();
|
||||
const body = String(els.get('gamecardbody')!['innerHTML']);
|
||||
assert.match(body, /Type<\/dt><dd[^>]*>Solitaire/, 'the opened card does not name the game type');
|
||||
assert.match(body, /Combined Revenue floor<\/dt><dd[^>]*>15/, 'the card does not carry the victory conditions');
|
||||
assert.match(body, /Days<\/dt><dd[^>]*>5/, 'the card does not say how long the game is');
|
||||
});
|
||||
|
||||
it('asks before the first deal — a bare visit shows the setup screen, not a dealt game', async () => {
|
||||
|
||||
Reference in New Issue
Block a user