Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6058f6c17e | ||
|
|
e62ea54259 | ||
|
|
d267f89a82 | ||
|
|
31b942cc38 | ||
|
|
bb1b661211 | ||
|
|
788e5f2eec | ||
|
|
a70b7f88f3 | ||
|
|
131538dc7c | ||
|
|
cf018b4a5f | ||
|
|
45cf521a40 | ||
|
|
e035dda5a3 | ||
|
|
f2c87b6871 |
+405
@@ -19,6 +19,411 @@ page as `v0.1.0 · <sha> · <date>`, so what is deployed can always be identifie
|
||||
|
||||
---
|
||||
|
||||
## 0.7.9 — 2026-08-30
|
||||
|
||||
Four pieces of feedback on the solitaire setup screen, plus the rules bug the second one exposed.
|
||||
|
||||
### The collision limits were dead settings in solitaire — now they are not
|
||||
|
||||
Asked to reword the collision entries to "the game ends immediately and results in a loss", which
|
||||
turned out to be unwriteable: `advance.ts` gated the whole §3.4 check on
|
||||
`mode === 'competitive' || mode === 'coop'`, and a solitaire game's mode is `'solitaire'`. So both
|
||||
limits were offered on the New Game dialog as live settings, rode into the config, and never fired.
|
||||
A solitaire player could set a limit of 1 and crash all game. The *existing* text beside them ("the
|
||||
game ends in a loss") was already false; the new wording would only have made it more so.
|
||||
|
||||
The exclusion was never a stated rule — §3.4 does not carve solitaire out — and nothing recorded a
|
||||
reason for it. **Jesse's ruling: the settings do what they say**, so the mode gate is gone rather
|
||||
than the controls.
|
||||
|
||||
**A solitaire game can therefore now end early, and that is measured rather than asserted.** Over
|
||||
200 standard games with the developer bot: `loss/collisionFloor` **1 game in 200**, with Days played
|
||||
falling 5.00 → 4.98 mean and a *minimum of 1* — a bad opening Day can now end a game outright.
|
||||
Collisions per game are unchanged (0.14 mean, max 3), which is the point: the ending is rare because
|
||||
crashes are, not because the check is lenient. `0` still switches either limit off, at one seat
|
||||
exactly as at four, and that path has its own test now.
|
||||
|
||||
Every figure in `TODO.md` quoted from a full-length solitaire run predates this.
|
||||
|
||||
### Where an Extra may start defaults to your own Control Point
|
||||
|
||||
Was "any player's". At one seat the two are the *same rule* — `apply.ts` only rejects `ownOffice`
|
||||
when `start.seat !== seatOf(s, player)`, which cannot happen — so this is a labelling fix with no
|
||||
gameplay effect and nothing to re-measure. The permissive label described a permission a lone player
|
||||
was never being granted, and named an "any player" they have no contrast with.
|
||||
|
||||
### The extension question was hidden behind the results screen
|
||||
|
||||
Jesse, 2026-08-30: "Solitaire game ended. I did not have an option to extend the game by a day."
|
||||
|
||||
**The engine and the Frame were right the whole time** — checked before changing anything: a
|
||||
solitaire game at the end of its timetable reaches `awaitingExtension` with `extensionVotes: [null]`,
|
||||
and `isExtendable` covers both `revenueFloor` and `daysElapsed`. `renderActions` reaches
|
||||
`renderEnding` for any non-active status, and `renderEnding` writes "play one more Day" and "end the
|
||||
game here" into `#actions`.
|
||||
|
||||
Then it opens `#resultsdlg`, **which is modal**. So the two buttons were rendered directly underneath
|
||||
a dialog whose only control was Close, and the player read a results screen that offered nothing but
|
||||
Close and concluded the game was over — which is precisely what it looked like.
|
||||
|
||||
The results dialog now carries the question itself, hidden unless a vote is actually pending:
|
||||
**Play One More Day** and **End the Game Here**, casting the same `game.extend` intent. The
|
||||
`#actions` buttons stay, because they are what remains once the dialog is closed and what a player
|
||||
who reopens it with "see the full results" comes back to.
|
||||
|
||||
**Why this survived being "verified".** `TODO.md` #35 recorded extended play as checked on
|
||||
`phoenix.local` — over the HTTP API, which renders no dialog. The browser path had never been run to
|
||||
the end of a timetable. Same shape as the three attempts before it: the thing that was verified was
|
||||
not the thing the player uses.
|
||||
|
||||
### `configWith` let the day count and the Revenue floor disagree
|
||||
|
||||
`minCombinedRevenue` fell back to `SOLO_CONFIG`'s constant — the floor for a *five*-Day game —
|
||||
whatever `days` said. So `configWith({ days: 1 })` asked a one-Day game to clear **15**, a figure a
|
||||
full five-Day game averages barely half of, and `configWith({ days: 10 })` asked for the same 15 a
|
||||
five-Day game does. It derives from the days it was actually given now.
|
||||
|
||||
Not a live fault: `createLocalSession` is the only caller, and the page always writes the floor
|
||||
itself, so no dealt game was ever wrong. It was found by a throwaway probe written to reproduce the
|
||||
extension bug above, which passed only `days` — which is exactly how the next caller would reach for
|
||||
it. At the default day count the answer is unchanged, since `SOLO_CONFIG`'s own floor is this same
|
||||
formula at `DEFAULT_DAYS`; the three new tests pin that as well as the derivation.
|
||||
|
||||
### A Heavy Grade shows which way it climbs
|
||||
|
||||
Jesse, 2026-08-30: "heavy grade mainline card tooltip states climbs east, but card doesn't show it."
|
||||
The Frame has carried `gradeUp` since the Division map was rebuilt and the tip has read "climbs
|
||||
east" all along — but the one Mainline card whose orientation is set per game, and the one where
|
||||
Helpers and Brakeman mean opposite things at opposite ends, drew nothing to tell the two apart.
|
||||
|
||||
A brown wedge in the card's lower right, rising toward the climb, with a bone arrow lying along its
|
||||
slope. **East is right on this map** (Gitea#18), which is what lets a wedge be read without a
|
||||
compass — and is the layout decision paying for itself again.
|
||||
|
||||
**Four passes, and the first three are worth recording because each failed differently.** An arrow
|
||||
up the hypotenuse had to start at the wedge's thin corner, where there is no height to draw in, so
|
||||
its head sat over the edge and read as clipped. Level, it was contained but did not read as
|
||||
climbing. At 30° — steeper than the wedge's own 22.5° — it had to be tucked into the fat half to
|
||||
survive. Lying **along** the slope is the shape that fits: the perpendicular gap to the hypotenuse
|
||||
is then constant down the whole arrow instead of closing at one end, so it can sit on the triangle's
|
||||
centroid. The wedge grew 44×19 → 58×24 to pay for that, because a centred arrow has *less* room than
|
||||
an off-centre one — the centroid is about 7px from the hypotenuse.
|
||||
|
||||
The final sizes are a search result rather than an eyeballed nudge: the roomiest arrow keeping all
|
||||
seven vertices clear of both edges, at 2.88px.
|
||||
|
||||
**A test that passed a visibly broken glyph is the reason this is pinned properly.** The first
|
||||
containment check bounded the arrow against the CARD, which it never left — while the wedge clipped
|
||||
it. The check is against the *triangle* now, with a 2px floor, plus the arrow being parallel to the
|
||||
wedge (derived from the wedge, so resizing it cannot leave a stale angle) and centred on the
|
||||
centroid.
|
||||
|
||||
**Orientation is always set — measured, not assumed**, since a wedge that invented a direction would
|
||||
be worse than no wedge. Across 400 seeds × 4 player counts: **535 Heavy Grades placed, 0 without an
|
||||
orientation**, 276 east / 259 west. `setup.ts` is the only place a Mainline node is created and it
|
||||
rolls the direction from the seed for every grade, so the `?? 'east'` fallbacks in `view.ts` and
|
||||
`advance.ts` are unreachable.
|
||||
|
||||
### Two setup screens, not three — the in-game dialog is deleted
|
||||
|
||||
Jesse, 2026-08-30: "If you are in the Solitaire game and you click the new game dialog, it should not
|
||||
go to a separate screen. We should reuse the Solitaire New Game Screen… in general we should reuse
|
||||
what we already have."
|
||||
|
||||
`#newgamedlg` was a third copy of the same questions and the one that drifted. It was shown ONLY to
|
||||
a solitaire player, and it asked "Everyone loses if **combined** Revenue at the end is under" — a
|
||||
table's question, put to one person — and explained Employee Rotation in full, in multiplayer terms,
|
||||
beside a control it had itself disabled. Both were on the list to re-word. Deleting the screen
|
||||
removes the drift instead of restating it, which is the cheaper fix and the one that cannot drift
|
||||
again.
|
||||
|
||||
**New game now opens the setup screen in place.** Not a navigation: the live session stays in memory,
|
||||
so the fields open on the rules actually being played — which is what the dialog was good for, and
|
||||
losing "change one dial, redeal, compare" would have been a real loss — and **Continue Existing Saved
|
||||
Game** puts the board straight back with no reload and no replay. Nothing is at risk either way:
|
||||
`render()` calls `save()` every frame, so the game in progress is always on disk.
|
||||
|
||||
**And the two remaining screens now match below their headers.** Each keeps its own opening — the
|
||||
lobby's join/secret section and "Create a new game" are multiplayer's alone — but from the parameters
|
||||
down they are one form: the same three fields in the same order (seed, players at the table, days),
|
||||
the same note about a seed and settings dealing the same railroad, and the same note about every
|
||||
chair being taken. Solitaire shows **Players at the table** too, locked at one, rather than omitting
|
||||
it — a control that is present and fixed says "this is the same form, at a table of one", where a
|
||||
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.
|
||||
|
||||
### The save warning, the buttons, and a claim that was simply false
|
||||
|
||||
Three more from Jesse reading the two screens side by side.
|
||||
|
||||
- **The save warning is a warning.** "That's a warning, not an 'oh by the way'." It had already moved
|
||||
off `.ng-note` earlier in this release; it is larger and heavier again now — 15px, weight 500,
|
||||
bright amber on a deeper ground with a 5px rule down the side. Worth noting what he was actually
|
||||
looking at: `phoenix.local` runs v0.7.8, where this line is still the small grey `.ng-note`. None
|
||||
of this release has been deployed.
|
||||
- **The buttons read the same on both screens**: **Continue saved game** and **Create new game**.
|
||||
Solitaire said "Continue Existing Saved Game" and "Deal New Game"; the lobby said "Create game".
|
||||
Three phrasings for two actions.
|
||||
- **"Off in every game type" is deleted from the Optional rules note, because it was not true.**
|
||||
Checked against the presets rather than taken on trust: `discardTimetabled` — §6.2's "a Timetabled
|
||||
train may be discarded" — ships **on** in all four types, not just Co-op. The note now says only
|
||||
what is true of all of them: "Each one changes how the game plays."
|
||||
|
||||
### A game you come back to has not begun
|
||||
|
||||
Jesse, 2026-08-30: "when you are continuing the saved game out of that screen, do not post a message
|
||||
that says 'The game has begun.' … it needs to say 'The game has resumed.'"
|
||||
|
||||
A restored game draws exactly like a dealt one — mid-Day, mid-phase, with a log already several turns
|
||||
deep — and **solitaire said nothing at all** to tell them apart. It flashes "The game has resumed —
|
||||
Day 3, Stage 7" now, on both ways back: the setup screen's **Continue saved game**, and a bare reload
|
||||
that restores the save.
|
||||
|
||||
**The same line was wrong on the multiplayer side, in the other direction.** `noteFirstFrame` guards
|
||||
on `firstFrameSeen`, which is per page-load — so re-entering a game this browser already held a seat
|
||||
in, by reloading mid-game or picking it out of the lobby's list, announced that the game had **begun**
|
||||
to somebody who had been playing it for an hour. `beginRemote` carries whether this is a rejoin now,
|
||||
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
|
||||
a loss…". The Revenue floor keeps its own wording, since it is settled at the end rather than on
|
||||
the spot.
|
||||
- **Employee Rotation** on the solitaire screen says "not applicable for solitaire" instead of
|
||||
"meaningless at a table of one, shown here so this screen and the lobby read as one list" — which
|
||||
explained the page's own construction to somebody who had not asked.
|
||||
- **The save warning is legible.** It sat in `.ng-note`, the same dim 11px grey as the twenty
|
||||
explanatory notes above it, while being the only thing on the screen describing something
|
||||
irreversible. It is 14px on an amber panel now — amber and not red because losing a save is a real
|
||||
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".
|
||||
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## 0.7.8 — 2026-08-30
|
||||
|
||||
### The setup screen was unreachable for anyone who had ever played
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "station-master",
|
||||
"version": "0.7.8",
|
||||
"version": "0.7.9",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Station Master — a railroad operations game",
|
||||
|
||||
+19
-5
@@ -1636,11 +1636,25 @@ function shiftChange(s: GameState, events: GameEvent[]): AdvanceResult {
|
||||
events.push({ type: 'stageBegan', day: s.clock.day, stage: s.clock.stage });
|
||||
}
|
||||
|
||||
// §3.4 — every mode but competitive-and-coop-only: a Day's collisions against `maxCollisionsPerDay`
|
||||
// and the game's running total against `maxCollisionsTotal`. `0` disables either check. Flat, not
|
||||
// scaled by player count — Jesse's call, 2026-08-20: more players is more independent chances to
|
||||
// collide, not a bigger shared budget.
|
||||
if (s.config.mode === 'competitive' || s.config.mode === 'coop') {
|
||||
/**
|
||||
* §3.4 — EVERY MODE, SOLITAIRE INCLUDED: a Day's collisions against `maxCollisionsPerDay` and the
|
||||
* game's running total against `maxCollisionsTotal`. `0` disables either check. Flat, not scaled
|
||||
* by player count — Jesse's call, 2026-08-20: more players is more independent chances to collide,
|
||||
* not a bigger shared budget.
|
||||
*
|
||||
* SOLITAIRE WAS EXCLUDED UNTIL 2026-08-30 and nothing said so. The gate here read `mode ===
|
||||
* 'competitive' || mode === 'coop'`, while `SOLO_CONFIG` carried both limits and the New Game
|
||||
* dialog offered them as live settings — so a solitaire player could set a collision limit, read
|
||||
* "the game ends in a loss" beside it, and crash as often as they liked. Found reviewing that
|
||||
* screen's wording (Jesse, 2026-08-30); his ruling is that the settings should do what they say,
|
||||
* so the gate is gone rather than the controls.
|
||||
*
|
||||
* A SOLITAIRE GAME CAN THEREFORE NOW END EARLY, which no measurement in `TODO.md` was taken
|
||||
* under. At the shipped defaults (3 a Day, 5 total) it is a rare ending rather than a common one —
|
||||
* the bot averages 0.06 collisions a game — but any figure quoted from a full-length run predates
|
||||
* it.
|
||||
*/
|
||||
{
|
||||
const perDayBreach =
|
||||
s.config.maxCollisionsPerDay > 0 && s.collisionsToday >= s.config.maxCollisionsPerDay;
|
||||
const totalBreach =
|
||||
|
||||
+88
-6
@@ -55,11 +55,12 @@ export function divisionSvg(nodes: DivisionView[], roster?: DivisionRoster | nul
|
||||
*
|
||||
* West DP · Mainline · [Limits … Office … Limits] · Mainline · [ … ] · Mainline · East DP
|
||||
*
|
||||
* SEATING. Players sit around a table, so the route is laid out the way they do: one row alone,
|
||||
* two rows facing, a horseshoe of three, a square of four. The Division is a LINE and not a loop
|
||||
* — trains enter at one Division Point and leave at the other — so the shape is deliberately left
|
||||
* open, with the two ends drawn as buffer stops facing each other across a marked gap. Closing it
|
||||
* into a ring would promise a connection the rules do not have.
|
||||
* ONE ROW AT EVERY SEAT COUNT (Gitea#18). It used to be laid out the way players sit — two rows
|
||||
* facing, a horseshoe of three, a square of four — and the reasoning for dropping that is at the
|
||||
* layout itself below. The Division is a LINE and not a loop — trains enter at one Division Point
|
||||
* and leave at the other — so the row is deliberately left open, with the two ends drawn as
|
||||
* buffer stops facing outward. Closing it into a ring would promise a connection the rules do
|
||||
* not have.
|
||||
*
|
||||
* Self-contained on purpose: the replay embeds this by `toString()`, so it may not reach for
|
||||
* anything outside its own body.
|
||||
@@ -108,7 +109,6 @@ export function divisionSvg(nodes: DivisionView[], roster?: DivisionRoster | nul
|
||||
* this is back to what a buffer stop actually needs.
|
||||
*/
|
||||
const PAD = 22;
|
||||
const SIDE_GAP = 34;
|
||||
|
||||
const esc = (t: string): string =>
|
||||
String(t).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c] ?? c);
|
||||
@@ -146,6 +146,12 @@ export function divisionSvg(nodes: DivisionView[], roster?: DivisionRoster | nul
|
||||
below?: Cell['trains'];
|
||||
/** Mainline cards only: §2.1 divides one into two regions. 0 elsewhere — no bars are drawn. */
|
||||
regions: number;
|
||||
/**
|
||||
* Which way a Heavy Grade climbs, or null on every other card. Drawn as a wedge, because the
|
||||
* tooltip said "climbs east" and the card itself showed nothing — so the one card whose
|
||||
* orientation the PLAYER chooses was the one card you had to hover to read (Jesse, 2026-08-30).
|
||||
*/
|
||||
gradeUp?: string | null;
|
||||
w: number;
|
||||
x: number;
|
||||
y: number;
|
||||
@@ -259,6 +265,7 @@ export function divisionSvg(nodes: DivisionView[], roster?: DivisionRoster | nul
|
||||
seat: null,
|
||||
// A Division Point is one region — the queue trains enter and leave the Division through.
|
||||
regions: dp ? 1 : (n.regions ?? 0),
|
||||
gradeUp: dp ? null : (n.gradeUp ?? null),
|
||||
w: dp ? CW.dp : CW.ml,
|
||||
});
|
||||
}
|
||||
@@ -357,6 +364,73 @@ export function divisionSvg(nodes: DivisionView[], roster?: DivisionRoster | nul
|
||||
out += `<line class="bs-region" x1="${c.x + 6 + RW * r}" y1="${c.y + RAIL_Y - 14}" x2="${c.x + 6 + RW * r}" y2="${c.y + RAIL_Y + 10}"/>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* WHICH WAY A HEAVY GRADE CLIMBS, drawn rather than only said.
|
||||
*
|
||||
* The tooltip has said "climbs east" since the Frame carried `gradeUp`, and the card showed
|
||||
* nothing — so the one Mainline card whose orientation the PLAYER sets, and the one where a
|
||||
* Helpers or Brakeman modifier means opposite things at opposite ends, was the one you had to
|
||||
* hover to read (Jesse, 2026-08-30).
|
||||
*
|
||||
* A wedge rising toward the climb, with an arrow up its slope. Two cues rather than one: the
|
||||
* wedge alone asks the reader to judge which end is taller, which at eleven pixels of rise is a
|
||||
* comparison rather than a glance. Bottom-right, clear of the left-aligned capacity line and
|
||||
* below the region bars, so it never lands under a train chip.
|
||||
*
|
||||
* `east` is RIGHT on this map and always has been (Gitea#18) — that is what makes a wedge
|
||||
* readable without a compass, and it is why the row layout is worth its width.
|
||||
*/
|
||||
if (c.gradeUp === 'east' || c.gradeUp === 'west') {
|
||||
const s = c.gradeUp === 'east' ? 1 : -1;
|
||||
const GW = 58;
|
||||
const RISE = 24;
|
||||
const gx1 = c.x + c.w - 9 - GW;
|
||||
const gx2 = c.x + c.w - 9;
|
||||
const yb = c.y + CH - 6;
|
||||
const peakX = s === 1 ? gx2 : gx1;
|
||||
out += `<polygon class="bs-grade" points="${gx1},${yb} ${gx2},${yb} ${peakX},${yb - RISE}"/>`;
|
||||
|
||||
/**
|
||||
* THE ARROW LIES ALONG THE WEDGE'S OWN SLOPE, CENTRED IN IT (Jesse, 2026-08-30).
|
||||
*
|
||||
* Parallel to the hypotenuse is the shape that fits: the perpendicular gap to the slope is
|
||||
* then CONSTANT along the whole arrow, instead of closing at one end the way a steeper line
|
||||
* does. The earlier 30° pass had to be tucked into the fat half to survive, because 30° is
|
||||
* steeper than this wedge climbs — at 58×24 the slope is 22.5°, and the arrow simply lies on
|
||||
* it.
|
||||
*
|
||||
* Centred on the TRIANGLE'S CENTROID (2/3 along the base, 1/3 up), which is the balance point
|
||||
* of the form rather than of its bounding box — centring on the box would push the arrow into
|
||||
* the thin corner where there is no height for it.
|
||||
*
|
||||
* The wedge grew 50×22 → 58×24 to pay for that: the centroid sits only ~7px from the
|
||||
* hypotenuse, so a centred arrow has less room than an off-centre one and needs a bigger form
|
||||
* to keep it. Sizes are the best fit found by search, clearing every edge by 2.88px;
|
||||
* `web.test.ts` re-derives it and fails under 2px.
|
||||
*
|
||||
* Worked in the wedge's own frame — `u` along the base from the thin corner, `h` up from it —
|
||||
* so a westward climb is one sign on `u` rather than a second set of coordinates.
|
||||
*/
|
||||
const L = 20;
|
||||
const HL = 6;
|
||||
const HW = 3.5;
|
||||
const T = 1.4;
|
||||
const A = Math.atan2(RISE, GW);
|
||||
const cos = Math.cos(A);
|
||||
const sin = Math.sin(A);
|
||||
const cu = (2 * GW) / 3;
|
||||
const ch = RISE / 3;
|
||||
const pt = (lx: number, ly: number): string => {
|
||||
const u = cu + lx * cos - ly * sin;
|
||||
const h = ch + lx * sin + ly * cos;
|
||||
return `${s === 1 ? gx1 + u : gx2 - u},${yb - h}`;
|
||||
};
|
||||
const H = L / 2;
|
||||
out +=
|
||||
`<polygon class="bs-gradeup" points="${pt(-H, -T)} ${pt(H - HL, -T)} ${pt(H - HL, -HW)} ` +
|
||||
`${pt(H, 0)} ${pt(H - HL, HW)} ${pt(H - HL, T)} ${pt(-H, T)}"/>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* ON THE DIVISION MAP, A TRAIN IS A CHIP — name, which way it points, how many cars.
|
||||
*
|
||||
@@ -1123,6 +1197,14 @@ export const BOARD_CSS = `
|
||||
/* The vertical bars a Mainline card is divided into (§2.1). Drawn faint: they are the ruler the
|
||||
train is measured against, not something to look at instead of the train. */
|
||||
.bs-region{stroke:#4a5361;stroke-width:1.2;stroke-dasharray:3 3}
|
||||
/* THE HEAVY GRADE WEDGE. Terrain, so it is coloured as terrain rather than as a warning.
|
||||
SOLID BROWN, fill and border the same (Jesse, 2026-08-30) — the first pass paired a desaturated
|
||||
fill with an amber arrow and the pair read reddish, which on a map that spends amber on "it is
|
||||
happening here" made a fixed piece of landscape look like a live alert. One flat brown recedes
|
||||
into scenery; the arrow is bone so the DIRECTION, which is the fact being reported, is the part
|
||||
that carries. */
|
||||
.bs-grade{fill:#6b5334;stroke:#6b5334;stroke-width:1}
|
||||
.bs-gradeup{fill:#f2e8d5}
|
||||
.bs-slot{fill:none;stroke:#5f6b7a;stroke-width:1.1;stroke-dasharray:3 2}
|
||||
.bs-slot.bs-occ{stroke-dasharray:none;stroke-width:1.6}
|
||||
/* CAR TYPE BY COLOUR, LOAD STATE BY FILL — the SAME distinction the train tray draws, because they
|
||||
|
||||
+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,
|
||||
|
||||
+57
-11
@@ -134,10 +134,25 @@ export type NewGameOptions = {
|
||||
|
||||
/** The same config with the New Game dialog's answers in it. */
|
||||
export function configWith(opts: NewGameOptions): GameConfig {
|
||||
const days = opts.days ?? SOLO_CONFIG.days;
|
||||
return {
|
||||
...SOLO_CONFIG,
|
||||
days: opts.days ?? SOLO_CONFIG.days,
|
||||
minCombinedRevenue: opts.minCombinedRevenue ?? SOLO_CONFIG.minCombinedRevenue,
|
||||
days,
|
||||
/**
|
||||
* DERIVED FROM THE DAYS ACTUALLY IN PLAY, not from `SOLO_CONFIG`'s five-Day constant.
|
||||
*
|
||||
* It fell back to the constant until 2026-08-30, so `configWith({ days: 1 })` asked a one-Day
|
||||
* game to clear **15** — a floor a five-Day game averages barely half of — and
|
||||
* `configWith({ days: 10 })` asked for the same 15 a five-Day game does. The two fields silently
|
||||
* disagreed, which is the one thing a "build me a config" helper must not let happen.
|
||||
*
|
||||
* Not a live fault when it was found: `createLocalSession` is the only caller, and the page
|
||||
* always writes `minCombinedRevenue` itself (`solitaireDefaults` re-derives it from the preset).
|
||||
* Found by a throwaway probe that passed only `days` — which is exactly how the next caller
|
||||
* would use this. At the default day count the answer is unchanged, since `SOLO_CONFIG`'s own
|
||||
* floor is this same formula at `DEFAULT_DAYS`.
|
||||
*/
|
||||
minCombinedRevenue: opts.minCombinedRevenue ?? collectiveRevenueFloor(1, days),
|
||||
maxCollisionsPerDay: opts.maxCollisionsPerDay ?? SOLO_CONFIG.maxCollisionsPerDay,
|
||||
maxCollisionsTotal: opts.maxCollisionsTotal ?? SOLO_CONFIG.maxCollisionsTotal,
|
||||
optionalRules: { ...SOLO_CONFIG.optionalRules, ...(opts.optionalRules ?? {}) },
|
||||
@@ -1107,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) {
|
||||
@@ -1119,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 });
|
||||
|
||||
}
|
||||
@@ -1220,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;
|
||||
@@ -1234,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.
|
||||
|
||||
+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 {
|
||||
|
||||
+301
-129
@@ -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.';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -506,7 +585,7 @@ const lobbyHandlers = {
|
||||
const record = readStore().games[gameId];
|
||||
if (!record) return;
|
||||
if (record.stage === 'game' && record.seat !== undefined) {
|
||||
beginRemote({ ...record, seat: record.seat });
|
||||
beginRemote({ ...record, seat: record.seat }, true);
|
||||
return;
|
||||
}
|
||||
// Still seated in a lobby that had not started: the stream puts us back on the seating screen,
|
||||
@@ -533,6 +612,8 @@ const HANDOFF_STALL_MS = 8000;
|
||||
let handoffOpenedAt = 0;
|
||||
let handoffStall: number | null = null;
|
||||
let firstFrameSeen = false;
|
||||
/** Set when this page entered a game it was already seated in, so the first frame says so. */
|
||||
let rejoiningRemote = false;
|
||||
|
||||
function setText(id: string, text: string): void {
|
||||
const el = document.getElementById(id);
|
||||
@@ -612,7 +693,7 @@ function noteDayEnd(f: Frame): void {
|
||||
* nothing saying this was the game just set up. `#phasenote` cannot help: it announces a CHANGE of
|
||||
* phase, and there is no previous phase to have changed from.
|
||||
*/
|
||||
function noteFirstFrame(f: Frame): void {
|
||||
function noteFirstFrame(f: Frame, rejoining = false): void {
|
||||
if (firstFrameSeen || isLocal(session)) return;
|
||||
firstFrameSeen = true;
|
||||
const held = Date.now() - handoffOpenedAt;
|
||||
@@ -621,14 +702,34 @@ function noteFirstFrame(f: Frame): void {
|
||||
window.setTimeout(
|
||||
() => {
|
||||
closeHandoff();
|
||||
/**
|
||||
* "BEGUN" IS ONLY TRUE ONCE. `firstFrameSeen` is per page-load, so re-entering a game this
|
||||
* browser already holds a seat in — a reload mid-game, or picking it out of the lobby's list
|
||||
* — announced that the game had begun, to a player who had been playing it for an hour.
|
||||
* Reported for the solitaire side by Jesse, 2026-08-30; the same line was wrong here.
|
||||
*/
|
||||
flashAnnounce(
|
||||
`The game has begun — ${type} · ${f.players.length} players · Day ${f.day}, Stage ${f.stage}`,
|
||||
`The game has ${rejoining ? 'resumed' : 'begun'} — ${type} · ${f.players.length} players · ` +
|
||||
`Day ${f.day}, Stage ${f.stage}`,
|
||||
);
|
||||
},
|
||||
Math.max(0, HANDOFF_BEAT_MS - held),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* COMING BACK TO A GAME, said out loud — the solitaire counterpart to `noteFirstFrame`.
|
||||
*
|
||||
* A restored game draws exactly like a dealt one: mid-Day, mid-phase, with a log already several
|
||||
* turns deep. Nothing distinguished "this is the game you left" from "this is a game that has just
|
||||
* started", and the multiplayer path had the opposite problem — it announced that the game had
|
||||
* BEGUN to a player rejoining one (Jesse, 2026-08-30: "do not post a message that says 'The game
|
||||
* has begun.' … it needs to say 'The game has resumed.'").
|
||||
*/
|
||||
function announceResumed(f: Frame): void {
|
||||
flashAnnounce(`The game has resumed — Day ${f.day}, Stage ${f.stage}`);
|
||||
}
|
||||
|
||||
/** Toggles the three mutually-exclusive top-level screens `play.html` defines — `#lobby` (Phase 4),
|
||||
* `#gameui` (the board, whether local or remote), and `#solitairesetup` (asked before the first
|
||||
* solitaire deal, the same way `#lobby` is asked before the first multiplayer one — Jesse,
|
||||
@@ -646,7 +747,7 @@ function showScreen(which: 'lobby' | 'gameui' | 'solitairesetup'): void {
|
||||
* earlier visit. Either way the token is what makes reconnection work (`lobby-and-sessions.md` §1),
|
||||
* so it is always written back here before anything else happens.
|
||||
*/
|
||||
function beginRemote(ready: LobbyReady): void {
|
||||
function beginRemote(ready: LobbyReady, rejoining = false): void {
|
||||
saveRemote({ token: ready.token, gameId: ready.gameId, gameCode: ready.gameCode, seat: ready.seat, stage: 'game' });
|
||||
gameCode = ready.gameCode;
|
||||
showScreen('gameui');
|
||||
@@ -656,6 +757,7 @@ function beginRemote(ready: LobbyReady): void {
|
||||
// banner (`#presence`), and it holds a beat so the game visibly begins.
|
||||
openHandoff();
|
||||
session = createRemoteSession(ready.token, ready.seat, abandonRemote);
|
||||
rejoiningRemote = rejoining;
|
||||
applyCapabilities();
|
||||
// A LocalSession has data the instant it is constructed; a RemoteSession does not — its first
|
||||
// real Frame only exists once the SSE connection's first push arrives, so the first render waits
|
||||
@@ -742,7 +844,7 @@ function start(): void {
|
||||
// session reports a dead game through `abandonRemote`, which lands in the lobby.
|
||||
const remembered = wantsSolitaire ? null : loadRemote();
|
||||
if (remembered && remembered.stage === 'game' && remembered.seat !== undefined) {
|
||||
beginRemote({ ...remembered, seat: remembered.seat });
|
||||
beginRemote({ ...remembered, seat: remembered.seat }, true);
|
||||
return;
|
||||
}
|
||||
/**
|
||||
@@ -799,13 +901,17 @@ function start(): void {
|
||||
const seed = requested !== null ? Number(requested) || 1 : Math.floor(Math.random() * 1e9);
|
||||
const local = createLocalSession(seed, solitaireDefaults(gameOptionsFromUrl(params)));
|
||||
session = local;
|
||||
if (saved && requested === null) local.restore(saved);
|
||||
const restored = Boolean(saved) && requested === null;
|
||||
if (saved && restored) local.restore(saved);
|
||||
|
||||
applyCapabilities();
|
||||
// Every render goes through the session, so the page redraws whenever the game says it changed —
|
||||
// which is what a remote session will use to push. Locally it fires on each accepted intent.
|
||||
session.subscribe(render);
|
||||
render();
|
||||
// Coming back to a game is not the same event as being dealt one, and the board looks identical
|
||||
// either way — mid-Day, mid-phase, with a log already deep (Jesse, 2026-08-30).
|
||||
if (restored) announceResumed(session.view());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -903,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. */
|
||||
@@ -934,7 +1051,7 @@ function rulesSummary(f: Frame): string {
|
||||
function render(): void {
|
||||
const f = session.view();
|
||||
const menu = session.menu();
|
||||
noteFirstFrame(f);
|
||||
noteFirstFrame(f, rejoiningRemote);
|
||||
|
||||
// Which squares the selected card or track piece may go on. Highlighting them is what turns the
|
||||
// coordinate list into a board: you pick the thing, then click where it goes.
|
||||
@@ -977,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, {
|
||||
@@ -1261,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.
|
||||
@@ -1381,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();
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1554,6 +1708,33 @@ function showResults(f: Frame): void {
|
||||
const body = document.getElementById('resultsbody');
|
||||
if (!dlg || !body) return;
|
||||
body.innerHTML = resultsHtml(f);
|
||||
|
||||
/**
|
||||
* ASK THE EXTENSION QUESTION ON THE THING THAT IS ACTUALLY IN FRONT OF THE PLAYER.
|
||||
*
|
||||
* This dialog opens itself at every ending and it is MODAL, so `renderEnding`'s own "play one more
|
||||
* Day" buttons — written into `#actions` — are behind it. The player read a results screen offering
|
||||
* nothing but Close and concluded the game was over, which is exactly what it looked like (Jesse,
|
||||
* 2026-08-30). Gitea#11 was verified over the HTTP API, where there is no dialog to be behind.
|
||||
*
|
||||
* The buttons in `#actions` stay, and are still correct: they are what remains after this is
|
||||
* closed, and what a player who reopened the results with "see the full results" comes back to.
|
||||
* Voting from either place submits the same intent.
|
||||
*/
|
||||
const yes = document.getElementById('rs-extend-yes') as HTMLButtonElement | null;
|
||||
const no = document.getElementById('rs-extend-no') as HTMLButtonElement | null;
|
||||
const asking = f.status === 'awaitingExtension' && f.extensionVotes[f.viewer] === null;
|
||||
if (yes && no) {
|
||||
yes.hidden = !asking;
|
||||
no.hidden = !asking;
|
||||
if (asking) {
|
||||
// `method="dialog"` closes it on click; the vote rides along. Assigned every time rather than
|
||||
// once, because `f` is a fresh Frame on each ending.
|
||||
yes.onclick = () => void session.submit({ type: 'game.extend', player: f.viewer, agree: true });
|
||||
no.onclick = () => void session.submit({ type: 'game.extend', player: f.viewer, agree: false });
|
||||
}
|
||||
}
|
||||
|
||||
// A redraw can arrive while it is open — `showModal` throws on an already-open dialog rather
|
||||
// than doing nothing (the same trap `noteDayEnd` documents).
|
||||
if (!dlg.open) dlg.showModal();
|
||||
@@ -1992,12 +2173,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 {
|
||||
@@ -2016,19 +2201,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;
|
||||
@@ -2110,65 +2297,26 @@ function commitNewGame(wired: WiredGameType, seedFieldValue: string): void {
|
||||
else location.search = next;
|
||||
}
|
||||
|
||||
/**
|
||||
* THE IN-GAME "NEW GAME" BUTTON GOES TO THE SETUP SCREEN (Jesse, 2026-08-30 — "it should not go to
|
||||
* a separate screen. We should reuse the Solitaire New Game Screen").
|
||||
*
|
||||
* `#newgamedlg` used to be a third copy of the same questions and the one that drifted: it carried
|
||||
* multiplayer wording on a screen only a solitaire player ever sees. It is deleted; this navigates
|
||||
* to the screen that already asks these questions properly.
|
||||
*
|
||||
* Nothing is lost on the way: `render()` calls `save()` every frame, so the game in progress is
|
||||
* always on disk, and the setup screen offers "Continue saved game" to come back to it.
|
||||
*/
|
||||
const newBtn = document.getElementById('newgame');
|
||||
const dlg = document.getElementById('newgamedlg') as HTMLDialogElement | null;
|
||||
if (newBtn && dlg) {
|
||||
const field = <T extends HTMLElement>(id: string): T => document.getElementById(id) as T;
|
||||
|
||||
/**
|
||||
* THE SAME FIVE GAME TYPES THE LOBBY OFFERS, and the same shared rules block under them.
|
||||
*
|
||||
* The dialog used to carry its own copy of the questions and its own idea of the defaults, which
|
||||
* is how it ended up with "where an Extra may start" that the lobby did not have and none of the
|
||||
* three optional rules that it did. Every screen now reads `presets.ts` and drives its block
|
||||
* through `settings-form.ts`; only Solitaire can actually be DEALT here, so the three multiplayer
|
||||
* types are shown disabled rather than hidden — what this screen offers and what the lobby offers
|
||||
* should read as one list, not two.
|
||||
*/
|
||||
const ng = wireGameTypeBlock('ng-', dlg);
|
||||
|
||||
/**
|
||||
* ASK FOR ALL OF IT, rather than documenting URL parameters in the title bar.
|
||||
*
|
||||
* It asked for the seed alone, through `prompt()`. The opening hand and the three revenue rates
|
||||
* were constants in the source, so trying a variation meant an edit and a rebuild — and balance is
|
||||
* the open question this game has (`TODO.md`). A dialog is what lets a playtest be a playtest.
|
||||
*
|
||||
* The dialog OPENS ON THE RULES IN PLAY rather than on the defaults: dealing a second game to
|
||||
* compare against the first is the common case, and re-entering settings each time is how a
|
||||
* comparison silently stops comparing. Which TYPE that is comes out of the comparison — a game
|
||||
* dealt at the Solitaire defaults reopens on Solitaire, and one that was tuned reopens on Custom
|
||||
* with every changed field marked.
|
||||
*/
|
||||
if (newBtn) {
|
||||
newBtn.onclick = () => {
|
||||
// The button itself is hidden for a session that cannot deal (`applyCapabilities`), but the
|
||||
// dialog's whole answer-reading/URL-navigating flow below assumes a LocalSession throughout, so
|
||||
// the guard is repeated — and `local` is captured as a `const` so the narrowing survives the
|
||||
// closures below it (see `renderUndo`'s identical note on why `session` itself cannot be).
|
||||
if (!isLocal(session)) return;
|
||||
const local = session;
|
||||
const f = local.view();
|
||||
const day = f.day;
|
||||
const started = f.status === 'active' && (day > 1 || f.stage > 1);
|
||||
if (started && !confirm(`Forget this game (seed ${local.seed()}, Day ${day}) and deal a new one?`)) return;
|
||||
|
||||
field<HTMLInputElement>('ng-seed').value = '';
|
||||
field<HTMLInputElement>('ng-days').value = String(f.days);
|
||||
ng.setBase('solitaire', 'solitaire');
|
||||
// The rules actually in play, then the comparison decides what to call them.
|
||||
ng.form.write(settingsOf(configFromFrame(f)), presetSettings('solitaire', 1, f.days));
|
||||
ng.refresh();
|
||||
dlg.showModal();
|
||||
showScreen('solitairesetup');
|
||||
// IN PLACE, not a navigation: the live session stays in memory, so the fields can open on the
|
||||
// rules actually being played and "Continue saved game" is just showing the board
|
||||
// again rather than a reload and a replay.
|
||||
runSolitaireSetup(new URLSearchParams(), true, session.view());
|
||||
};
|
||||
|
||||
/**
|
||||
* One handler for every way the dialog can close — the Deal button, the Cancel button, and Esc,
|
||||
* which `<dialog>` answers with an empty `returnValue` and no submit event at all.
|
||||
*/
|
||||
dlg.addEventListener('close', () => {
|
||||
if (dlg.returnValue !== 'deal') return;
|
||||
commitNewGame(ng, field<HTMLInputElement>('ng-seed').value);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2181,7 +2329,7 @@ if (newBtn && dlg) {
|
||||
* Solitaire defaults, since there is no live game to compare against yet, and reuses the identical
|
||||
* `wireGameTypeBlock`/`commitNewGame` pair the in-game dialog uses — the two are one design, not two.
|
||||
*/
|
||||
function runSolitaireSetup(params: URLSearchParams, hasSave = false): void {
|
||||
function runSolitaireSetup(params: URLSearchParams, hasSave = false, live: Frame | null = null): void {
|
||||
const screen = document.getElementById('solitairesetup');
|
||||
const dealBtn = document.getElementById('ss-deal');
|
||||
if (!screen || !dealBtn) return;
|
||||
@@ -2193,25 +2341,49 @@ function runSolitaireSetup(params: URLSearchParams, hasSave = false): void {
|
||||
const seedField = document.getElementById('ss-seed') as HTMLInputElement | null;
|
||||
if (seedField) seedField.value = params.get('seed') ?? '';
|
||||
|
||||
/**
|
||||
* WHAT THE FIELDS OPEN ON, and it is not the same question in both directions.
|
||||
*
|
||||
* Reached mid-game from "New game", this opens on the rules CURRENTLY IN PLAY — that is what the
|
||||
* deleted dialog was good for, and losing it would make "change one dial and redeal to compare"
|
||||
* impossible. Reached from the splash, there is no game to read, so it opens on the plain
|
||||
* Solitaire defaults.
|
||||
*/
|
||||
if (live) {
|
||||
const daysField = document.getElementById('ss-days') as HTMLInputElement | null;
|
||||
if (daysField) daysField.value = String(live.days);
|
||||
ss.setBase('solitaire', 'solitaire');
|
||||
ss.form.write(settingsOf(configFromFrame(live)), presetSettings('solitaire', 1, live.days));
|
||||
ss.refresh();
|
||||
} else {
|
||||
ss.selectPreset('solitaire');
|
||||
}
|
||||
|
||||
/**
|
||||
* THE WAY BACK TO A GAME IN PROGRESS, and the reason the door is allowed to outrank a save at all.
|
||||
* Dealing from here calls `clearSave()`, so a player who reached this screen from the splash — by
|
||||
* clicking "Play solitaire", which nobody reads as "throw away what I was playing" — needs their
|
||||
* game one button away and needs to be told what Deal costs.
|
||||
*
|
||||
* Resuming is a navigation to the BARE url rather than a session built here: `start()` already
|
||||
* resumes a save on a bare load, and routing both paths through it keeps one place that turns a
|
||||
* URL into a game.
|
||||
* Mid-game the game is still in memory, so going back is just showing it again. From the splash
|
||||
* there is nothing loaded yet, so it is a navigation to the bare URL and `start()` restores the
|
||||
* save — one place that turns a URL into a game, either way.
|
||||
*/
|
||||
const resumeBtn = document.getElementById('ss-resume');
|
||||
const savedNote = document.getElementById('ss-saved-note');
|
||||
const canResume = hasSave || live !== null;
|
||||
if (resumeBtn) {
|
||||
resumeBtn.hidden = !hasSave;
|
||||
resumeBtn.onclick = () => void (location.search = '');
|
||||
resumeBtn.hidden = !canResume;
|
||||
resumeBtn.onclick = live
|
||||
? () => {
|
||||
showScreen('gameui');
|
||||
render();
|
||||
announceResumed(session.view());
|
||||
}
|
||||
: () => void (location.search = '');
|
||||
}
|
||||
if (savedNote) savedNote.hidden = !hasSave;
|
||||
if (savedNote) savedNote.hidden = !canResume;
|
||||
|
||||
ss.selectPreset('solitaire');
|
||||
dealBtn.onclick = () => commitNewGame(ss, seedField?.value ?? '');
|
||||
}
|
||||
|
||||
|
||||
+20
-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':
|
||||
@@ -347,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);
|
||||
@@ -381,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)}`;
|
||||
}
|
||||
|
||||
+122
-216
@@ -76,6 +76,19 @@ dialog input:focus{outline:none;border-color:#4d6fa8}
|
||||
padding:5px 14px;cursor:pointer;font:inherit;font-size:13px}
|
||||
.ng-buttons button:hover{border-color:#4d6fa8}
|
||||
#ng-deal{background:#31527f;border-color:#4d6fa8}
|
||||
/* The save warning is the one thing on this screen that describes something IRREVERSIBLE, and it
|
||||
sat in `.ng-note` — the same dim 11px grey as the twenty explanatory notes above it, which is
|
||||
where the eye has already learned there is nothing to act on. Sized and coloured to be read
|
||||
(Jesse, 2026-08-30). Amber rather than red: losing a saved game is a real cost, not a danger, and
|
||||
red here would outrank the actual rules of the game sitting above it. */
|
||||
#ss-saved-note{font-size:15px;font-weight:500;line-height:1.55;color:#ffcf70;background:#332a15;
|
||||
border:1px solid #b8912c;border-left:5px solid #e0a83c;border-radius:5px;padding:12px 14px;
|
||||
margin:18px 0 0}
|
||||
#ss-saved-note b{color:#ffe3a6}
|
||||
/* Two live choices, so neither is the quiet one: `Create new game` keeps the primary blue it has when
|
||||
it is the only button, and `Continue` is given the same weight rather than reading as a cancel. */
|
||||
#ss-deal{background:#31527f;border-color:#4d6fa8}
|
||||
#ss-resume{background:#2f5340;border-color:#4f8a68}
|
||||
main{display:grid;grid-template-columns:minmax(0,1fr) 400px;gap:14px;padding:14px;align-items:start}
|
||||
@media(max-width:1100px){main{grid-template-columns:1fr}}
|
||||
section{background:var(--panel);border:1px solid var(--line);border-radius:7px;
|
||||
@@ -95,7 +108,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)}
|
||||
@@ -203,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
|
||||
@@ -212,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;
|
||||
@@ -307,9 +340,10 @@ ul.blocked li{padding:2px 0}
|
||||
|
||||
<!-- THE LOBBY (Phase 4) — shown instead of the game UI whenever there is no game yet to play: no
|
||||
stored session token, or a token whose game hasn't started. `lobby.ts` owns everything in here;
|
||||
`main.ts` only decides whether THIS div or `#gameui` below is the one currently visible.
|
||||
`#newgamedlg` at the very end of the body is solitaire-only, and asks the same questions through
|
||||
the same shared module (`settings-form.ts`) — the two blocks are generated from one template. -->
|
||||
`main.ts` only decides whether THIS div, `#solitairesetup` or `#gameui` is the one visible.
|
||||
`#solitairesetup` asks the same questions of a solitaire player, through the same shared module
|
||||
(`settings-form.ts`) — the two blocks are generated from one template, and since 2026-08-30
|
||||
they are the ONLY two: the in-game dialog that was a third copy is gone. -->
|
||||
<div id="lobby" hidden>
|
||||
<header><b><a href="./index.html" class="home">Station Master</a></b> — <span class="dim">Multiplayer</span></header>
|
||||
|
||||
@@ -402,12 +436,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>
|
||||
@@ -421,7 +456,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
|
||||
@@ -431,7 +466,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">
|
||||
|
||||
@@ -505,13 +540,13 @@ ul.blocked li{padding:2px 0}
|
||||
</div>
|
||||
<div class="set-row" id="lb-colday-row">
|
||||
<label class="ng-gate"><input type="checkbox" id="lb-colday-on" checked>
|
||||
<span>The game ends and everyone loses if collisions in one Day reach</span>
|
||||
<span>The game ends immediately and results in a loss if collisions in one Day reach</span>
|
||||
<input id="lb-colday" type="number" min="0" step="1" class="gate-num"></label>
|
||||
<span class="set-hint" id="lb-colday-hint"></span>
|
||||
</div>
|
||||
<div class="set-row" id="lb-coltotal-row">
|
||||
<label class="ng-gate"><input type="checkbox" id="lb-coltotal-on" checked>
|
||||
<span>The game ends and everyone loses after this many collisions in the whole game</span>
|
||||
<span>The game ends immediately and results in a loss after this many collisions in the whole game</span>
|
||||
<input id="lb-coltotal" type="number" min="0" step="1" class="gate-num"></label>
|
||||
<span class="set-hint" id="lb-coltotal-hint"></span>
|
||||
</div>
|
||||
@@ -522,7 +557,7 @@ ul.blocked li{padding:2px 0}
|
||||
|
||||
<div class="set-group">
|
||||
<h3>Optional rules</h3>
|
||||
<p class="ng-note">Off in every game type; each one changes how the game plays.</p>
|
||||
<p class="ng-note">Each one changes how the game plays.</p>
|
||||
<div class="set-row" id="lb-visibility-row">
|
||||
<label class="ng-num"><span>Reduced Visibility — five switching Moves instead of six in the
|
||||
night Stages (1–3 and 11–12)</span>
|
||||
@@ -555,7 +590,7 @@ ul.blocked li{padding:2px 0}
|
||||
</details>
|
||||
|
||||
<div class="lb-span">
|
||||
<button id="lb-create" type="button">Create game</button>
|
||||
<button id="lb-create" type="button">Create new game</button>
|
||||
<p class="lb-error" id="lb-create-err" role="alert"></p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -616,16 +651,29 @@ ul.blocked li{padding:2px 0}
|
||||
<p class="ng-note">One railroad, one player, five full days by default — everything below is
|
||||
yours to change before you deal. Clearing the Revenue floor wins; falling short loses.</p>
|
||||
|
||||
<!-- THE SAME THREE PARAMETERS THE LOBBY ASKS, in the same order, with the same note under them
|
||||
(Jesse, 2026-08-30: "everything beneath that should be the same"). The table size is here
|
||||
rather than hidden because it is one of the three things that describe a game, and leaving
|
||||
it out made this screen a different form that happened to share a rules block. It is LOCKED
|
||||
at one: a `LocalSession` runs the engine in this browser and a table needs a server, which
|
||||
is the same reason the four multiplayer game types are shown disabled below. -->
|
||||
<div class="lb-params">
|
||||
<label class="ng-num"><span>Seed</span>
|
||||
<input id="ss-seed" type="text" inputmode="numeric" autocomplete="off" placeholder="blank for a random seed"></label>
|
||||
<label class="ng-num"><span>Players at the table</span>
|
||||
<select id="ss-players" disabled>
|
||||
<option value="1" selected>1</option>
|
||||
</select></label>
|
||||
<label class="ng-num"><span>Days</span>
|
||||
<input id="ss-days" type="number" min="1" max="20" step="1" value="5"></label>
|
||||
</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 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>
|
||||
@@ -639,19 +687,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>
|
||||
@@ -708,7 +757,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>
|
||||
@@ -717,13 +767,13 @@ ul.blocked li{padding:2px 0}
|
||||
</div>
|
||||
<div class="set-row" id="ss-colday-row">
|
||||
<label class="ng-gate"><input type="checkbox" id="ss-colday-on" checked>
|
||||
<span>The game ends in a loss if collisions in one Day reach</span>
|
||||
<span>The game ends immediately and results in a loss if collisions in one Day reach</span>
|
||||
<input id="ss-colday" type="number" min="0" step="1" class="gate-num"></label>
|
||||
<span class="set-hint" id="ss-colday-hint"></span>
|
||||
</div>
|
||||
<div class="set-row" id="ss-coltotal-row">
|
||||
<label class="ng-gate"><input type="checkbox" id="ss-coltotal-on" checked>
|
||||
<span>The game ends in a loss after this many collisions in the whole game</span>
|
||||
<span>The game ends immediately and results in a loss after this many collisions in the whole game</span>
|
||||
<input id="ss-coltotal" type="number" min="0" step="1" class="gate-num"></label>
|
||||
<span class="set-hint" id="ss-coltotal-hint"></span>
|
||||
</div>
|
||||
@@ -734,7 +784,7 @@ ul.blocked li{padding:2px 0}
|
||||
|
||||
<div class="set-group">
|
||||
<h3>Optional rules</h3>
|
||||
<p class="ng-note">Off in every game type; each one changes how the game plays.</p>
|
||||
<p class="ng-note">Each one changes how the game plays.</p>
|
||||
<div class="set-row" id="ss-visibility-row">
|
||||
<label class="ng-num"><span>Reduced Visibility — five switching Moves instead of six in the
|
||||
night Stages (1–3 and 11–12)</span>
|
||||
@@ -742,8 +792,7 @@ ul.blocked li{padding:2px 0}
|
||||
<span class="set-hint" id="ss-visibility-hint"></span>
|
||||
</div>
|
||||
<div class="set-row" id="ss-rotation-row">
|
||||
<label class="ng-num"><span>Employee Rotation — meaningless at a table of one, shown here so
|
||||
this screen and the lobby read as one list</span>
|
||||
<label class="ng-num"><span>Employee Rotation — not applicable for solitaire</span>
|
||||
<input id="ss-rotation" type="checkbox" disabled></label>
|
||||
<span class="set-hint" id="ss-rotation-hint"></span>
|
||||
</div>
|
||||
@@ -769,12 +818,13 @@ ul.blocked li{padding:2px 0}
|
||||
save (`commitNewGame` calls `clearSave`), so without a way back the door would be a way to
|
||||
lose a game in progress — and the door is reached by clicking "Play solitaire", which nobody
|
||||
reads as "discard what I was playing". -->
|
||||
<p class="ng-note" id="ss-saved-note" hidden>You have a solitaire game in progress. Dealing a new
|
||||
one below replaces it — there is no undo for that.</p>
|
||||
<p id="ss-saved-note" hidden><b>You have a solitaire game in progress.</b> Creating a new game
|
||||
replaces it permanently — there is no undo. Choose <b>Continue saved game</b> to pick it
|
||||
up where you left off.</p>
|
||||
|
||||
<menu class="ng-buttons">
|
||||
<button id="ss-resume" type="button" hidden>Continue saved game</button>
|
||||
<button id="ss-deal" type="button">Deal</button>
|
||||
<button id="ss-deal" type="button">Create new game</button>
|
||||
</menu>
|
||||
</section>
|
||||
</div>
|
||||
@@ -787,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
|
||||
@@ -810,7 +863,7 @@ ul.blocked li{padding:2px 0}
|
||||
</span>
|
||||
<button id="undo" title="Take the last action back. The save is the seed plus the moves made, so this replays the game without the last one — as far back as you like.">Undo</button>
|
||||
<button id="savefile" title="Download this game as a save file you can replay or share">Save replay</button>
|
||||
<button id="newgame" title="Deal a fresh game. You choose the seed, the opening hand and what the three economies pay. Undo steps back one action at a time; this throws the whole game away, so download the replay first if you want to keep it.">New game</button>
|
||||
<button id="newgame" title="Set up a fresh game — the seed, the table, the opening hand and what the three economies pay. Opens the same screen a new solitaire game starts from, with your current rules filled in; your game in progress is kept until you press Deal, and Continue puts it straight back.">New game</button>
|
||||
<button id="multiplayer" title="Create or join a Competitive or Co-op game on this server, with other players.">Multiplayer</button>
|
||||
<!-- LEAVING A RUNNING GAME. Reported by Jesse 2026-08-23: "if I'm a player in the middle of the
|
||||
game and I need to leave, how do I leave the game, clear the token from my browser so I can
|
||||
@@ -844,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.
|
||||
@@ -888,190 +941,34 @@ 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>
|
||||
|
||||
<!-- ===================================================================
|
||||
NEW GAME — the seed, the opening hand, and what the three economies pay.
|
||||
<!-- THE IN-GAME "NEW GAME" BUTTON GOES TO THE SOLITAIRE SETUP SCREEN — there is no second
|
||||
dialog any more (Jesse, 2026-08-30: "it should not go to a separate screen. We should reuse the
|
||||
Solitaire New Game Screen… in general we should reuse what we already have").
|
||||
|
||||
It was a `prompt()` asking for a seed. Two of the three things that decide what kind of game
|
||||
you are about to play had no way in at all: the opening hand had been changed twice with no
|
||||
way back to the earlier rule, and the revenue rates were constants in the source. Balance is
|
||||
the open question in this game (`TODO.md`), and the way to settle it is to deal several games
|
||||
at different settings — which needs a dialog, not a rebuild.
|
||||
`#newgamedlg` was a third copy of the same questions, and the one that drifted: it kept the
|
||||
multiplayer wording ("Everyone loses if COMBINED Revenue…") on a screen only ever shown to a
|
||||
solitaire player, and explained Employee Rotation in full beside a control it had disabled.
|
||||
Deleting it removes the drift rather than re-wording it.
|
||||
|
||||
Every control has a default that is the recommended answer, so DEAL with nothing touched is a
|
||||
complete, sensible game. The settings ride in the URL alongside the seed, because a seed alone
|
||||
no longer names a game: `?seed=430` with a different opening hand is a different railroad.
|
||||
==================================================================== -->
|
||||
Nothing is lost by navigating away mid-game: `render()` calls `save()` on every frame, so the
|
||||
game in progress is always on disk, and the setup screen offers "Continue saved game"
|
||||
to come straight back to it. -->
|
||||
</div><!-- /gameui -->
|
||||
|
||||
<dialog id="newgamedlg" aria-labelledby="ng-title">
|
||||
<form method="dialog" id="newgameform">
|
||||
<h2 class="big" id="ng-title">New game</h2>
|
||||
|
||||
<!-- THE SAME BLOCK THE LOBBY USES, same shared module, same order — the two screens are one
|
||||
design. Only Solitaire can be dealt here; the multiplayer types are shown disabled rather
|
||||
than hidden, so what this screen offers and what the lobby offers read as one list. -->
|
||||
<div class="lb-params">
|
||||
<label class="ng-num"><span>Seed</span>
|
||||
<input id="ng-seed" type="text" inputmode="numeric" autocomplete="off" placeholder="blank for a random seed"></label>
|
||||
<label class="ng-num"><span>Days</span>
|
||||
<input id="ng-days" type="number" min="1" max="20" step="1" value="5"></label>
|
||||
</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>
|
||||
|
||||
<h3>Game type</h3>
|
||||
<div class="set-row" id="ng-type-row">
|
||||
<label class="ng-radio"><input type="radio" name="ng-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>
|
||||
<label class="ng-radio"><input type="radio" name="ng-type" value="coop" checked>
|
||||
<span><b>Co-op</b><br><span class="dim">Everyone’s Revenue is one table score. You win together or lose together.</span></span></label>
|
||||
<label class="ng-radio"><input type="radio" name="ng-type" value="competitive">
|
||||
<span><b>Competitive</b><br><span class="dim">Highest Revenue wins — unless the table misses its combined minimum, and then everyone loses.</span></span></label>
|
||||
<label class="ng-radio"><input type="radio" name="ng-type" value="cutthroat">
|
||||
<span><b>Cutthroat</b><br><span class="dim">Highest Revenue wins, and nothing is shared — the only way everyone loses is three collisions in one Day.</span></span></label>
|
||||
<label class="ng-radio"><input type="radio" name="ng-type" value="custom">
|
||||
<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="ng-type-note"></p>
|
||||
|
||||
<details id="ng-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>
|
||||
<div class="set-groups">
|
||||
|
||||
<div class="set-group">
|
||||
<h3>Starting hand</h3>
|
||||
<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="ng-hand-row">
|
||||
<label class="ng-radio"><input type="radio" name="ng-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>
|
||||
<label class="ng-radio"><input type="radio" name="ng-hand" value="sixRandom" checked>
|
||||
<span><b>Six random cards</b><br><span class="dim">Twice the choice, still no guaranteed track — the first turn is a discard.</span></span></label>
|
||||
<label class="ng-radio"><input type="radio" name="ng-hand" value="threeTrackThreeOther">
|
||||
<span><b>Three random track and three random non-track cards</b><br><span class="dim">Dealt from two piles, so the district you can build is dealt rather than waited for.</span></span></label>
|
||||
<span class="set-hint" id="ng-hand-hint"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="set-group">
|
||||
<h3>Where an Extra may start</h3>
|
||||
<p class="ng-note">The player who plays an Extra Train card chooses where its Crew Tray goes,
|
||||
and the place decides which way it runs — a Division Point sends it away from itself; in the
|
||||
middle of the railroad the player picks east or west. The Division Points and the Interchange
|
||||
belong to nobody and are always available. Starting one inside a district is the part that
|
||||
favours a seat, so it is set here. An Office must be a Control Point whatever this says: a
|
||||
Whistle Post never qualifies.</p>
|
||||
<div class="set-row" id="ng-extra-row">
|
||||
<label class="ng-radio"><input type="radio" name="ng-extra" value="divisionPointsOnly">
|
||||
<span><b>Division Points and the Interchange only</b><br><span class="dim">The strictest reading. Every Extra begins on shared ground.</span></span></label>
|
||||
<label class="ng-radio"><input type="radio" name="ng-extra" value="ownOffice">
|
||||
<span><b>Also the playing player’s own Control Point</b><br><span class="dim">You may start one at home, but not in somebody else’s district.</span></span></label>
|
||||
<label class="ng-radio"><input type="radio" name="ng-extra" value="anyOffice">
|
||||
<span><b>Also any player’s Control Point</b><br><span class="dim">The most permissive — an Extra may be planted in another player’s district.</span></span></label>
|
||||
<span class="set-hint" id="ng-extra-hint"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="set-group">
|
||||
<h3>Revenue</h3>
|
||||
<p class="ng-note">What each piece of work pays, 0 to 5. A coach pays when it is boarded and
|
||||
again when it is detrained; a load pays when it is made up and again when it is broken. Zero
|
||||
switches an economy off so the others can be read.</p>
|
||||
<div class="set-row" id="ng-passenger-row">
|
||||
<label class="ng-num"><span>Passenger revenue per coach</span>
|
||||
<input id="ng-passenger" type="number" min="0" max="5" step="1" value="1"></label>
|
||||
<span class="set-hint" id="ng-passenger-hint"></span>
|
||||
</div>
|
||||
<div class="set-row" id="ng-freight-row">
|
||||
<label class="ng-num"><span>Freight revenue per load</span>
|
||||
<input id="ng-freight" type="number" min="0" max="5" step="1" value="1"></label>
|
||||
<span class="set-hint" id="ng-freight-hint"></span>
|
||||
</div>
|
||||
<div class="set-row" id="ng-transit-row">
|
||||
<label class="ng-num"><span>Train revenue per transit</span>
|
||||
<input id="ng-transit" type="number" min="0" max="5" step="1" value="0"></label>
|
||||
<span class="set-hint" id="ng-transit-hint"></span>
|
||||
</div>
|
||||
<p class="ng-note">A transit pays every player, once, when a train runs off the end of the
|
||||
Division — the one thing nobody has to work for.</p>
|
||||
</div>
|
||||
|
||||
<div class="set-group">
|
||||
<h3>Victory conditions</h3>
|
||||
<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="ng-minrev-row">
|
||||
<label class="ng-gate"><input type="checkbox" id="ng-minrev-on" checked>
|
||||
<span>Everyone loses if combined Revenue at the end is under</span>
|
||||
<input id="ng-minrev" type="number" min="0" step="1" class="gate-num"></label>
|
||||
<span class="set-hint" id="ng-minrev-hint"></span>
|
||||
</div>
|
||||
<div class="set-row" id="ng-colday-row">
|
||||
<label class="ng-gate"><input type="checkbox" id="ng-colday-on" checked>
|
||||
<span>The game ends and everyone loses if collisions in one Day reach</span>
|
||||
<input id="ng-colday" type="number" min="0" step="1" class="gate-num"></label>
|
||||
<span class="set-hint" id="ng-colday-hint"></span>
|
||||
</div>
|
||||
<div class="set-row" id="ng-coltotal-row">
|
||||
<label class="ng-gate"><input type="checkbox" id="ng-coltotal-on" checked>
|
||||
<span>The game ends and everyone loses after this many collisions in the whole game</span>
|
||||
<input id="ng-coltotal" type="number" min="0" step="1" class="gate-num"></label>
|
||||
<span class="set-hint" id="ng-coltotal-hint"></span>
|
||||
</div>
|
||||
<p class="ng-note">The opponent-directed cards — Derail, Watertower, Hobo Jungle and the
|
||||
nineteen others, along with the seven that answer them — are not implemented yet, so no game
|
||||
type deals them whatever else is set here.</p>
|
||||
</div>
|
||||
|
||||
<div class="set-group">
|
||||
<h3>Optional rules</h3>
|
||||
<p class="ng-note">Off in every game type; each one changes how the game plays.</p>
|
||||
<div class="set-row" id="ng-visibility-row">
|
||||
<label class="ng-num"><span>Reduced Visibility — five switching Moves instead of six in the
|
||||
night Stages (1–3 and 11–12)</span>
|
||||
<input id="ng-visibility" type="checkbox"></label>
|
||||
<span class="set-hint" id="ng-visibility-hint"></span>
|
||||
</div>
|
||||
<div class="set-row" id="ng-rotation-row">
|
||||
<label class="ng-num"><span>Employee Rotation — at the end of each Day everyone moves one
|
||||
chair left and takes over the next station up the line. Your Revenue and the Fedora go with
|
||||
you; the district stays where it is</span>
|
||||
<input id="ng-rotation" type="checkbox"></label>
|
||||
<span class="set-hint" id="ng-rotation-hint"></span>
|
||||
</div>
|
||||
<div class="set-row" id="ng-toolbox-row">
|
||||
<label class="ng-num"><span>Emergency Toolbox — everyone starts holding a Red Flag, so a hand
|
||||
of four; play or discard down to three on the first turn</span>
|
||||
<input id="ng-toolbox" type="checkbox"></label>
|
||||
<span class="set-hint" id="ng-toolbox-hint"></span>
|
||||
</div>
|
||||
<div class="set-row" id="ng-tossloco-row">
|
||||
<label class="ng-num"><span>A Timetabled train may be discarded — toss it face-up to a
|
||||
Department slot, where a rival may pick it up. Turn this off and a train card can only ever
|
||||
be played onto the timetable. An Extra is never discardable either way</span>
|
||||
<input id="ng-tossloco" type="checkbox"></label>
|
||||
<span class="set-hint" id="ng-tossloco-hint"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<menu class="ng-buttons">
|
||||
<span class="ng-note" id="ng-multiplayer-note" style="margin:0 auto 0 0">Use the <b>Multiplayer</b> button instead — it creates or joins a game on this server.</span>
|
||||
<button value="cancel" id="ng-cancel" type="submit" formnovalidate>Cancel</button>
|
||||
<button value="deal" id="ng-deal" type="submit">Deal</button>
|
||||
</menu>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<!-- THE DAY ROLLING OVER (Gitea#10). A Day turns inside the automatic phases, so it happens
|
||||
between one click and the next; the phase banner and the announcement flash both fade before
|
||||
someone reading the board notices them. A modal stops and waits, which is the whole request:
|
||||
@@ -1088,10 +985,19 @@ ul.blocked li{padding:2px 0}
|
||||
<!-- THE END-OF-GAME RESULTS (Gitea#16). Filled by `resultsHtml` and opened from `renderEnding`,
|
||||
which puts it up once per ending unasked and leaves a button to reopen it. Reopenable matters:
|
||||
Gitea#11 lets a table play past the end, and continuing must not cost you the results screen. -->
|
||||
<!-- THE EXTENSION QUESTION IS ASKED HERE, not only behind this dialog (Gitea#11 + #16).
|
||||
This opens ITSELF at every ending, and it is modal — so `renderEnding`'s "play one more Day"
|
||||
buttons, which it writes into `#actions`, sit underneath it. A player saw a results screen whose
|
||||
only control was Close and reasonably concluded the game was over: reported by Jesse
|
||||
2026-08-30, "Solitaire game ended. I did not have an option to extend the game by a day."
|
||||
Gitea#11 was verified over the HTTP API, which renders no dialog, so the browser never was.
|
||||
The two extension buttons are hidden unless the game is actually awaiting a vote. -->
|
||||
<dialog id="resultsdlg" aria-labelledby="rs-title">
|
||||
<form method="dialog">
|
||||
<div id="resultsbody"></div>
|
||||
<menu class="ng-buttons">
|
||||
<button value="extend-yes" id="rs-extend-yes" type="submit" hidden>Play One More Day</button>
|
||||
<button value="extend-no" id="rs-extend-no" type="submit" hidden>End the Game Here</button>
|
||||
<button value="ok" id="rs-ok" type="submit">Close</button>
|
||||
</menu>
|
||||
</form>
|
||||
|
||||
+8
-2
@@ -119,8 +119,14 @@ export const PRESETS: readonly Preset[] = [
|
||||
revenueFloor: (players, days) => collectiveRevenueFloor(players, days),
|
||||
rules: {
|
||||
startingHand: SIX,
|
||||
// Nobody else's district exists, so "any Control Point" and "your own" are the same rule.
|
||||
extraStart: 'anyOffice',
|
||||
/**
|
||||
* Nobody else's district exists, so "any Control Point" and "your own" are the same rule —
|
||||
* `apply.ts` only ever rejects `ownOffice` when `start.seat !== seatOf(s, player)`, which
|
||||
* cannot happen at one seat. It said `anyOffice` until 2026-08-30, which was true and read
|
||||
* wrong: a solitaire player has no "any player" to contrast themselves with, so the permissive
|
||||
* label described a permission nobody was being granted. Jesse's call; no gameplay effect.
|
||||
*/
|
||||
extraStart: 'ownOffice',
|
||||
passengerPerCoach: 1,
|
||||
freightPerLoad: 1,
|
||||
trainPerTransit: 0,
|
||||
|
||||
+24
-1
@@ -655,13 +655,36 @@ describe('victory conditions (§3, Gap 10e) — unified 2026-08-20', () => {
|
||||
assert.notEqual(s.status, 'finished');
|
||||
});
|
||||
|
||||
it('solitaire never checks the collision floor, whatever the counts', () => {
|
||||
it('solitaire checks the collision floor too, like every other mode', () => {
|
||||
/**
|
||||
* REVERSED 2026-08-30, and this test previously asserted the opposite ("solitaire never checks
|
||||
* the collision floor, whatever the counts").
|
||||
*
|
||||
* The exclusion was never a stated rule — §3.4 does not carve solitaire out — and nothing on
|
||||
* screen reflected it: `SOLO_CONFIG` carried both limits, the New Game dialog offered them as
|
||||
* live settings, and the text beside them said the game would end in a loss. A solitaire player
|
||||
* could set a limit of 1 and crash all game. Found reviewing that screen's wording; Jesse's
|
||||
* ruling is that the settings do what they say.
|
||||
*/
|
||||
const s = game(1, { mode: 'solitaire', maxCollisionsPerDay: 1, maxCollisionsTotal: 1 });
|
||||
s.collisionsToday = 99;
|
||||
s.collisionsTotal = 99;
|
||||
s.clock.stage = 1;
|
||||
s.clock.phase = 'shiftChange';
|
||||
advance(s);
|
||||
assert.equal(s.status, 'finished');
|
||||
assert.equal(s.outcome!.reason, 'collisionFloor');
|
||||
});
|
||||
|
||||
it('still lets a solitaire game switch the collision floor off with 0', () => {
|
||||
// The disable path is what a player who does not want the new ending reaches for, so it has to
|
||||
// work at one seat exactly as it does at four.
|
||||
const s = game(1, { mode: 'solitaire', maxCollisionsPerDay: 0, maxCollisionsTotal: 0 });
|
||||
s.collisionsToday = 99;
|
||||
s.collisionsTotal = 99;
|
||||
s.clock.stage = 1;
|
||||
s.clock.phase = 'shiftChange';
|
||||
advance(s);
|
||||
assert.notEqual(s.status, 'finished');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -59,8 +59,11 @@ describe('what each game type is', () => {
|
||||
assert.equal(presetSettings('cutthroat', 4, 5).extraStart, 'anyOffice');
|
||||
assert.equal(presetSettings('coop', 4, 5).extraStart, 'ownOffice');
|
||||
assert.equal(presetSettings('competitive', 4, 5).extraStart, 'ownOffice');
|
||||
// At one player the two rules are the same rule.
|
||||
assert.equal(presetSettings('solitaire', 1, 5).extraStart, 'anyOffice');
|
||||
// At one player the two rules ARE the same rule — `apply.ts` only rejects `ownOffice` when the
|
||||
// start is another seat's, which cannot happen. Solitaire said `anyOffice` until 2026-08-30:
|
||||
// true, and it read wrong, since a lone player has no "any player" to be contrasted with. The
|
||||
// label changed and the behaviour did not.
|
||||
assert.equal(presetSettings('solitaire', 1, 5).extraStart, 'ownOffice');
|
||||
});
|
||||
|
||||
it('leaves every optional rule off, in every type', () => {
|
||||
|
||||
+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', () => {
|
||||
|
||||
+558
-85
@@ -21,6 +21,7 @@ import { URLSearchParams as NodeURLSearchParams } from 'node:url';
|
||||
import { cardDescription, cardName, describeIntent, variantLabel } from '../src/sim/view.ts';
|
||||
import { variantsFor } from '../src/engine/track.ts';
|
||||
import { divisionSvg, officeSvg } from '../src/sim/board-svg.ts';
|
||||
import type { DivisionView } from '../src/sim/view.ts';
|
||||
import { ENHANCEMENT_RULES, STAGES_PER_DAY } from '../src/engine/content.ts';
|
||||
import { dayEndHtml, facilitiesHtml, resultsHtml, timetableHtml } from '../src/web/panels.ts';
|
||||
import { turnChartHtml } from '../src/sim/turnchart.ts';
|
||||
@@ -32,6 +33,8 @@ import { createGame as createEngineGame } from '../src/engine/setup.ts';
|
||||
import { advance as advanceEngine } from '../src/engine/advance.ts';
|
||||
import type { GameConfig } from '../src/engine/state.ts';
|
||||
import {
|
||||
configWith,
|
||||
SOLO_CONFIG,
|
||||
overHandLimit,
|
||||
actionGroups,
|
||||
actionMenu,
|
||||
@@ -1365,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++) {
|
||||
@@ -1459,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) =>
|
||||
@@ -1704,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) =>
|
||||
@@ -1809,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: () => {},
|
||||
@@ -2546,6 +2628,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
|
||||
@@ -2832,6 +2955,107 @@ describe('the Division map shows the whole route', () => {
|
||||
return divisionSvg(snapshot(s, [], null).division);
|
||||
};
|
||||
|
||||
it('draws which way a Heavy Grade climbs, instead of only saying it in the tooltip', () => {
|
||||
/**
|
||||
* REPORTED BY JESSE 2026-08-30: "heavy grade mainline card tooltip states climbs east, but card
|
||||
* doesn't show it." The Frame has carried `gradeUp` since the Division map was rebuilt, and the
|
||||
* tip has read "climbs east" all along — but the one Mainline card whose orientation the PLAYER
|
||||
* sets, and the one where Helpers and Brakeman mean opposite things at opposite ends, drew
|
||||
* nothing to distinguish the two.
|
||||
*
|
||||
* Asserted on the geometry rather than on a screenshot: what can actually go wrong here is the
|
||||
* wedge pointing the wrong way or hanging off the card, and both are numbers.
|
||||
*/
|
||||
const card = (gradeUp: string | null): DivisionView =>
|
||||
({
|
||||
kind: 'ml', label: 'Heavy Grade', trains: [], capacity: 1,
|
||||
modifiers: [], gradeUp, regions: 3, what: 'three regions',
|
||||
} as unknown as DivisionView);
|
||||
|
||||
const peakOf = (svg: string): [number, number][] | null => {
|
||||
const m = /<polygon class="bs-grade" points="([^"]+)"/.exec(svg);
|
||||
return m ? (m[1]!.split(' ').map((p) => p.split(',').map(Number) as [number, number])) : null;
|
||||
};
|
||||
|
||||
// EAST IS RIGHT on this map (Gitea#18), which is the whole reason a wedge can be read without a
|
||||
// compass — so the apex belongs at the greater x when the grade climbs east, and the lesser when
|
||||
// it climbs west.
|
||||
const east = peakOf(divisionSvg([card('east')]));
|
||||
assert.ok(east, 'a Heavy Grade climbing east draws no wedge at all');
|
||||
const [ea, eb, epeak] = east;
|
||||
assert.equal(epeak![0], Math.max(ea![0], eb![0]), 'the wedge climbs the wrong way for east');
|
||||
|
||||
const west = peakOf(divisionSvg([card('west')]));
|
||||
assert.ok(west, 'a Heavy Grade climbing west draws no wedge at all');
|
||||
const [wa, wb, wpeak] = west;
|
||||
assert.equal(wpeak![0], Math.min(wa![0], wb![0]), 'the wedge climbs the wrong way for west');
|
||||
|
||||
// Every other Mainline card is flat and must stay unmarked, or the wedge stops meaning anything.
|
||||
assert.equal(peakOf(divisionSvg([card(null)])), null, 'a card with no grade drew one anyway');
|
||||
|
||||
/**
|
||||
* THE ARROW LIVES INSIDE THE WEDGE, with room to spare.
|
||||
*
|
||||
* The first pass ran it up the hypotenuse, which starts at the wedge's thin corner where there
|
||||
* is no height to draw in, so the head sat over the edge and read as clipped — reported exactly
|
||||
* that way. A bounding-box check would have passed it: every point was on the card. What matters
|
||||
* is containment in the TRIANGLE, and a margin big enough to see, so both are asserted.
|
||||
*/
|
||||
for (const dir of ['east', 'west'] as const) {
|
||||
const svg = divisionSvg([card(dir)]);
|
||||
const wedge = peakOf(svg)!;
|
||||
const [w1, w2, apex] = wedge;
|
||||
const yBase = w1![1];
|
||||
const xMin = Math.min(w1![0], w2![0]);
|
||||
const xMax = Math.max(w1![0], w2![0]);
|
||||
const arrow = /<polygon class="bs-gradeup" points="([^"]+)"/.exec(svg)?.[1];
|
||||
assert.ok(arrow, `the ${dir} grade draws no arrow`);
|
||||
|
||||
const av = arrow.trim().split(/\s+/).map((p) => p.split(',').map(Number) as [number, number]);
|
||||
for (const [ax, ay] of av) {
|
||||
// 0 at the thin corner, 1 at the apex — the wedge's height scales with it.
|
||||
const t = apex![0] === xMax ? (ax - xMin) / (xMax - xMin) : (xMax - ax) / (xMax - xMin);
|
||||
const ceiling = yBase - (yBase - apex![1]) * t;
|
||||
assert.ok(ax >= xMin && ax <= xMax, `the ${dir} arrow leaves the wedge sideways at ${ax}`);
|
||||
assert.ok(
|
||||
yBase - ay >= 2 && ay - ceiling >= 2,
|
||||
`the ${dir} arrow comes within 2px of the wedge edge at ${ax},${ay} — it will read as clipped`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* IT LIES ALONG THE WEDGE'S OWN SLOPE, AND IS CENTRED IN THE FORM.
|
||||
*
|
||||
* Derived from the wedge rather than hardcoded, so resizing the wedge cannot leave the arrow
|
||||
* at a stale angle — the assertion is "parallel", which is the property that makes the gap to
|
||||
* the hypotenuse constant along the arrow instead of closing at one end.
|
||||
*
|
||||
* Measured on the AXIS: the two tail vertices straddle it by the shaft's half-thickness, so
|
||||
* measuring from a corner under-reads by a few degrees. That mistake cost a wrong reading once
|
||||
* already.
|
||||
*/
|
||||
const tail: [number, number] = [(av[0]![0] + av[6]![0]) / 2, (av[0]![1] + av[6]![1]) / 2];
|
||||
const tip = av[3]!;
|
||||
const dx = tip[0] - tail[0];
|
||||
const climb = (Math.atan2(-(tip[1] - tail[1]), Math.abs(dx)) * 180) / Math.PI;
|
||||
const slope = (Math.atan2(yBase - apex![1], xMax - xMin) * 180) / Math.PI;
|
||||
assert.ok(
|
||||
Math.abs(climb - slope) < 0.5,
|
||||
`the ${dir} arrow climbs at ${climb.toFixed(1)}° but the wedge rises at ${slope.toFixed(1)}°`,
|
||||
);
|
||||
assert.equal(dx > 0, dir === 'east', `the ${dir} arrow points away from the summit`);
|
||||
|
||||
// Centred on the TRIANGLE's centroid — 2/3 along the base toward the apex, 1/3 up — not on
|
||||
// the bounding box, which would put it in the thin corner where there is no height for it.
|
||||
const midX = (tail[0] + tip[0]) / 2;
|
||||
const midY = (tail[1] + tip[1]) / 2;
|
||||
const cX = apex![0] === xMax ? xMin + ((xMax - xMin) * 2) / 3 : xMax - ((xMax - xMin) * 2) / 3;
|
||||
const cY = yBase - (yBase - apex![1]) / 3;
|
||||
assert.ok(Math.abs(midX - cX) < 1, `the ${dir} arrow is off the centroid horizontally`);
|
||||
assert.ok(Math.abs(midY - cY) < 1, `the ${dir} arrow is off the centroid vertically`);
|
||||
}
|
||||
});
|
||||
|
||||
it('expands each Office into its Running Track, Limits to Limits', () => {
|
||||
// The map used to collapse a whole district into one "Office" box, so the track a train
|
||||
// actually runs along was invisible on the only view that shows where trains are.
|
||||
@@ -3339,6 +3563,34 @@ describe('the Day rolling over says so (Gitea#10)', () => {
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('configWith', () => {
|
||||
/**
|
||||
* The Revenue floor is a function of how long the game is, so a helper that takes `days` and
|
||||
* defaults the floor has to derive it. It fell back to `SOLO_CONFIG`'s five-Day constant until
|
||||
* 2026-08-30, which let the two fields disagree: a one-Day game was asked to clear 15, a floor a
|
||||
* five-Day game averages barely half of. Found by a probe that passed only `days` — which is how
|
||||
* the next caller would reach for it.
|
||||
*/
|
||||
it('derives the Revenue floor from the days it was given', () => {
|
||||
assert.equal(configWith({ days: 1 }).minCombinedRevenue, 3, 'a one-Day game kept the five-Day floor');
|
||||
assert.equal(configWith({ days: 10 }).minCombinedRevenue, 30, 'a ten-Day game kept the five-Day floor');
|
||||
});
|
||||
|
||||
it('leaves the default day count exactly where it was', () => {
|
||||
// The fix must not move the floor anybody is actually playing against: SOLO_CONFIG's own floor
|
||||
// is this same formula at DEFAULT_DAYS, so the default path is unchanged.
|
||||
assert.equal(configWith({}).minCombinedRevenue, SOLO_CONFIG.minCombinedRevenue);
|
||||
assert.equal(configWith({ days: SOLO_CONFIG.days }).minCombinedRevenue, SOLO_CONFIG.minCombinedRevenue);
|
||||
});
|
||||
|
||||
it('still lets a caller name a floor that has nothing to do with the length', () => {
|
||||
// Deriving is the DEFAULT, not a rule — the New Game dialog and the lobby both let a player set
|
||||
// a floor directly, and that has to survive being passed through here.
|
||||
assert.equal(configWith({ days: 10, minCombinedRevenue: 4 }).minCombinedRevenue, 4);
|
||||
assert.equal(configWith({ days: 3, minCombinedRevenue: 0 }).minCombinedRevenue, 0, 'an off floor was re-derived');
|
||||
});
|
||||
});
|
||||
|
||||
describe('the end-of-game results screen (Gitea#16)', () => {
|
||||
/**
|
||||
* A finished game, built by running the clock off the end of a real one rather than by hand — the
|
||||
@@ -3362,6 +3614,36 @@ describe('the end-of-game results screen (Gitea#16)', () => {
|
||||
return snapshot(s, [], null);
|
||||
};
|
||||
|
||||
it('asks the extension question ON the results dialog, not only behind it (Gitea#11)', () => {
|
||||
/**
|
||||
* REPORTED BY JESSE 2026-08-30: "Solitaire game ended. I did not have an option to extend the
|
||||
* game by a day."
|
||||
*
|
||||
* The engine and the Frame were right all along — a solitaire game at the end of its timetable
|
||||
* reaches `awaitingExtension` with an uncast vote, asserted below. What went wrong is that
|
||||
* `renderEnding` writes its two buttons into `#actions` and then opens `#resultsdlg`, which is
|
||||
* MODAL: the question was underneath a dialog whose only control was Close. Gitea#11 was
|
||||
* verified over the HTTP API, which renders no dialog, so the browser path never was.
|
||||
*/
|
||||
const f = finished();
|
||||
assert.equal(f.status, 'awaitingExtension', 'a solitaire game no longer pauses to ask');
|
||||
assert.equal(f.extensionVotes[f.viewer], null, 'the viewer has somehow already voted');
|
||||
|
||||
const html = readFileSync(join(dist, 'play.html'), 'utf8');
|
||||
for (const id of ['rs-extend-yes', 'rs-extend-no']) {
|
||||
assert.ok(html.includes(`id="${id}"`), `the results dialog cannot ask: no #${id}`);
|
||||
}
|
||||
|
||||
// And they are wired: shown only while a vote is pending, and casting the same intent the
|
||||
// `#actions` buttons do. Read from the source, since the dialog needs a DOM to drive.
|
||||
const src = readFileSync(join(root, 'src/web/main.ts'), 'utf8');
|
||||
const fn = /function showResults\(f: Frame\): void \{[\s\S]*?\n\}/.exec(src)?.[0] ?? '';
|
||||
assert.ok(fn !== '', 'showResults moved and this test cannot see it');
|
||||
assert.match(fn, /awaitingExtension/, 'the dialog does not know the game is asking');
|
||||
assert.match(fn, /'game\.extend'/, 'the dialog offers no way to answer');
|
||||
assert.match(fn, /yes\.hidden = !asking/, 'the buttons are not hidden on a game that is over');
|
||||
});
|
||||
|
||||
it('never prints a raw enum at the player', () => {
|
||||
// The bug the issue opens on: `GAME OVER — revenueFloor` is an internal identifier, shown at
|
||||
// the one moment the game has the player's whole attention.
|
||||
@@ -3391,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.
|
||||
@@ -3577,9 +3903,9 @@ describe('the lobby screen', () => {
|
||||
'lb-hand': group(['threeRandom', 'sixRandom', 'threeTrackThreeOther'], 'sixRandom'),
|
||||
'lb-extra': group(['divisionPointsOnly', 'ownOffice', 'anyOffice'], 'anyOffice'),
|
||||
'lb-type': group(['solitaire', 'coop', 'competitive', 'cutthroat', 'custom'], 'coop'),
|
||||
'ng-hand': group(['threeRandom', 'sixRandom', 'threeTrackThreeOther'], 'sixRandom'),
|
||||
'ng-extra': group(['divisionPointsOnly', 'ownOffice', 'anyOffice'], 'anyOffice'),
|
||||
'ng-type': group(['solitaire', 'coop', 'competitive', 'cutthroat', 'custom'], 'solitaire'),
|
||||
'ss-hand': group(['threeRandom', 'sixRandom', 'threeTrackThreeOther'], 'sixRandom'),
|
||||
'ss-extra': group(['divisionPointsOnly', 'ownOffice', 'anyOffice'], 'anyOffice'),
|
||||
'ss-type': group(['solitaire', 'coop', 'competitive', 'cutthroat', 'custom'], 'solitaire'),
|
||||
};
|
||||
const matching = (sel: string): Radio[] => {
|
||||
const name = /name="([^"]+)"/.exec(sel)?.[1] ?? '';
|
||||
@@ -3589,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: '',
|
||||
@@ -3703,8 +4039,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 () => {
|
||||
@@ -3832,7 +4173,7 @@ describe('the lobby screen', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('the lobby and the dialog ask the same questions', () => {
|
||||
describe('the lobby and the setup screen ask the same questions', () => {
|
||||
/**
|
||||
* THE DRIFT GUARD.
|
||||
*
|
||||
@@ -3847,20 +4188,20 @@ describe('the lobby and the dialog ask the same questions', () => {
|
||||
return readFileSync(join(dist, 'play.html'), 'utf8');
|
||||
};
|
||||
|
||||
it('carries every field of the shared block on all three screens', () => {
|
||||
it('carries every field of the shared block on both screens', () => {
|
||||
// `ss-` joined `lb-`/`ng-` 2026-08-29: the pre-game solitaire setup screen drives the identical
|
||||
// block ("asking first is the only path"). Same drift guard, one more prefix.
|
||||
const html = page();
|
||||
for (const prefix of ['lb-', 'ng-', 'ss-']) {
|
||||
for (const prefix of ['lb-', 'ss-']) {
|
||||
for (const selector of fieldSelectors(prefix)) {
|
||||
assert.ok(html.includes(selector), `the ${prefix} block is missing ${selector}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('offers all five game types on all three screens', () => {
|
||||
it('offers all five game types on both screens', () => {
|
||||
const html = page();
|
||||
for (const prefix of ['lb-', 'ng-', 'ss-']) {
|
||||
for (const prefix of ['lb-', 'ss-']) {
|
||||
for (const type of ['solitaire', 'coop', 'competitive', 'cutthroat', 'custom']) {
|
||||
assert.ok(
|
||||
html.includes(`name="${prefix}type" value="${type}"`),
|
||||
@@ -3887,13 +4228,17 @@ describe('the lobby and the dialog ask the same questions', () => {
|
||||
// The opponent-directed cards are unbuilt, and `buildDeck` holds them out however the config is
|
||||
// set — so the checkbox could not do anything, on either screen. The fact is stated in words.
|
||||
const html = page();
|
||||
assert.ok(!html.includes('id="ng-pvp"'), 'the dialog still has the dead PvP checkbox');
|
||||
assert.ok(!html.includes('id="lb-pvp"'), 'the lobby still has the dead PvP checkbox');
|
||||
assert.ok(!html.includes('id="ss-pvp"'), 'the setup screen still has the dead PvP checkbox');
|
||||
// The in-game dialog was a THIRD copy of this block and the one that drifted — it kept the
|
||||
// multiplayer wording on a solitaire-only screen. Deleted 2026-08-30; nothing may reintroduce
|
||||
// a prefix that no screen owns.
|
||||
assert.ok(!/id="ng-/.test(html), 'the deleted in-game dialog has come back');
|
||||
assert.match(html, /opponent-directed cards[\s\S]{0,120}not implemented yet/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the New Game dialog', () => {
|
||||
describe('the solitaire setup screen', () => {
|
||||
/**
|
||||
* DRIVEN THROUGH THE EMITTED BUNDLE, like the highlight test above, because the thing that can go
|
||||
* wrong here is wiring rather than logic: an id that does not match the HTML, a handler on the
|
||||
@@ -3935,14 +4280,10 @@ describe('the New Game dialog', () => {
|
||||
}));
|
||||
// ONE set per page, not one per element: the block is addressed through the document, and a stub
|
||||
// that handed each element its own copy would let a broken selector still pass.
|
||||
// ONE set, matching the markup's own `checked` defaults. There were two until 2026-08-30, one
|
||||
// per screen, because the in-game dialog was a second copy of this block — it is deleted, and
|
||||
// the setup screen it folded into is the only thing on the page driving these radios now.
|
||||
const groups: Record<string, Radio[]> = {
|
||||
'ng-hand': group(['threeRandom', 'sixRandom', 'threeTrackThreeOther'], 'sixRandom'),
|
||||
'ng-extra': group(['divisionPointsOnly', 'ownOffice', 'anyOffice'], 'anyOffice'),
|
||||
'ng-type': group(['solitaire', 'coop', 'competitive', 'cutthroat', 'custom'], 'coop'),
|
||||
// The pre-game setup screen (Gitea, "asking first is the only path", 2026-08-29) drives the
|
||||
// same shared block under the `ss-` prefix — one set here too, matching the markup's own
|
||||
// `checked` defaults rather than the dialog's (Solitaire, not Co-op: there is no live game to
|
||||
// reopen on, so the static default IS the Solitaire default).
|
||||
'ss-hand': group(['threeRandom', 'sixRandom', 'threeTrackThreeOther'], 'sixRandom'),
|
||||
'ss-extra': group(['divisionPointsOnly', 'ownOffice', 'anyOffice'], 'anyOffice'),
|
||||
'ss-type': group(['solitaire', 'coop', 'competitive', 'cutthroat', 'custom'], 'solitaire'),
|
||||
@@ -3957,7 +4298,17 @@ describe('the New Game dialog', () => {
|
||||
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: '',
|
||||
@@ -4014,15 +4365,15 @@ describe('the New Game dialog', () => {
|
||||
|
||||
/** What every field of the block reads, so a test can assert the whole form at once. */
|
||||
const readForm = (els: Map<string, Record<string, unknown>>, groups: Record<string, { value: string; checked: boolean }[]>) => ({
|
||||
hand: groups['ng-hand']!.find((r) => r.checked)?.value,
|
||||
extra: groups['ng-extra']!.find((r) => r.checked)?.value,
|
||||
type: groups['ng-type']!.find((r) => r.checked)?.value,
|
||||
passenger: els.get('ng-passenger')!['value'],
|
||||
freight: els.get('ng-freight')!['value'],
|
||||
transit: els.get('ng-transit')!['value'],
|
||||
days: els.get('ng-days')!['value'],
|
||||
minrev: els.get('ng-minrev')!['value'],
|
||||
tossloco: els.get('ng-tossloco')!['checked'],
|
||||
hand: groups['ss-hand']!.find((r) => r.checked)?.value,
|
||||
extra: groups['ss-extra']!.find((r) => r.checked)?.value,
|
||||
type: groups['ss-type']!.find((r) => r.checked)?.value,
|
||||
passenger: els.get('ss-passenger')!['value'],
|
||||
freight: els.get('ss-freight')!['value'],
|
||||
transit: els.get('ss-transit')!['value'],
|
||||
days: els.get('ss-days')!['value'],
|
||||
minrev: els.get('ss-minrev')!['value'],
|
||||
tossloco: els.get('ss-tossloco')!['checked'],
|
||||
});
|
||||
|
||||
it('opens on the rules in play, so a second game can be dealt to compare with the first', async () => {
|
||||
@@ -4031,9 +4382,9 @@ describe('the New Game dialog', () => {
|
||||
const { els, groups } = await load('?seed=430&hand=sixRandom&passenger=2&freight=3&transit=4');
|
||||
(els.get('newgame')!['onclick'] as () => void)();
|
||||
|
||||
const dlg = els.get('newgamedlg')!;
|
||||
assert.equal(dlg['open'], true, 'the New game button did not open the dialog');
|
||||
assert.equal(els.get('ng-seed')!['value'], '', 'the seed box kept the last game’s seed');
|
||||
assert.equal(els.get('solitairesetup')!['hidden'], false, 'the New game button did not open the setup screen');
|
||||
assert.equal(els.get('gameui')!['hidden'], true, 'the board is still showing over the setup screen');
|
||||
assert.equal(els.get('ss-seed')!['value'], '', 'the seed box kept the last game’s seed');
|
||||
const form = readForm(els, groups);
|
||||
assert.equal(form.passenger, '2');
|
||||
assert.equal(form.freight, '3');
|
||||
@@ -4067,39 +4418,43 @@ describe('the New Game dialog', () => {
|
||||
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('ng-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',
|
||||
);
|
||||
});
|
||||
|
||||
it('offers the multiplayer types, disabled — one list across both screens, dealt from one of them', async () => {
|
||||
const { els, groups } = await load('?seed=430');
|
||||
(els.get('newgame')!['onclick'] as () => void)();
|
||||
const disabled = groups['ng-type']!
|
||||
const disabled = groups['ss-type']!
|
||||
.filter((r) => (r as { disabled: boolean }).disabled)
|
||||
.map((r) => r.value);
|
||||
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.ok(
|
||||
readFileSync(join(dist, 'play.html'), 'utf8').includes('id="ng-multiplayer-note"'),
|
||||
'nothing on the dialog 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 () => {
|
||||
const { els, groups } = await load('?seed=430&transit=4');
|
||||
(els.get('newgame')!['onclick'] as () => void)();
|
||||
els.get('ng-days')!['value'] = '8';
|
||||
(els.get('ng-days')!['oninput'] as () => void)();
|
||||
els.get('ss-days')!['value'] = '8';
|
||||
(els.get('ss-days')!['oninput'] as () => void)();
|
||||
// 3 × 1 player × 8 days: the floor follows the length, and changing the length is not a rule
|
||||
// change, so this is still Solitaire rather than Custom.
|
||||
assert.equal(readForm(els, groups).minrev, '24', 'the Revenue floor did not follow the Day count');
|
||||
|
||||
const solitaire = groups['ng-type']!.find((r) => r.value === 'solitaire')!;
|
||||
for (const r of groups['ng-type']!) r.checked = r === solitaire;
|
||||
const solitaire = groups['ss-type']!.find((r) => r.value === 'solitaire')!;
|
||||
for (const r of groups['ss-type']!) r.checked = r === solitaire;
|
||||
(solitaire as { onchange: (() => void) | null }).onchange!();
|
||||
|
||||
const form = readForm(els, groups);
|
||||
@@ -4112,19 +4467,17 @@ describe('the New Game dialog', () => {
|
||||
const { els, groups, nav: n } = await load('?seed=430');
|
||||
(els.get('newgame')!['onclick'] as () => void)();
|
||||
|
||||
const dlg = els.get('newgamedlg')!;
|
||||
els.get('ng-seed')!['value'] = '99';
|
||||
for (const r of groups['ng-hand']!) r.checked = r.value === 'threeTrackThreeOther';
|
||||
els.get('ng-passenger')!['value'] = '5';
|
||||
els.get('ng-freight')!['value'] = '0';
|
||||
els.get('ng-transit')!['value'] = '2';
|
||||
els.get('ng-toolbox')!['checked'] = true;
|
||||
dlg['returnValue'] = 'deal';
|
||||
(dlg['close'] as () => void)();
|
||||
els.get('ss-seed')!['value'] = '99';
|
||||
for (const r of groups['ss-hand']!) r.checked = r.value === 'threeTrackThreeOther';
|
||||
els.get('ss-passenger')!['value'] = '5';
|
||||
els.get('ss-freight')!['value'] = '0';
|
||||
els.get('ss-transit')!['value'] = '2';
|
||||
els.get('ss-toolbox')!['checked'] = true;
|
||||
(els.get('ss-deal')!['onclick'] as () => void)();
|
||||
|
||||
assert.equal(
|
||||
n.search,
|
||||
'?seed=99&hand=threeTrackThreeOther&extra=anyOffice&passenger=5&freight=0&transit=2' +
|
||||
'?seed=99&hand=threeTrackThreeOther&extra=ownOffice&passenger=5&freight=0&transit=2' +
|
||||
'&days=5&minrev=15&colday=3&coltotal=5&tool=1',
|
||||
);
|
||||
});
|
||||
@@ -4132,55 +4485,177 @@ describe('the New Game dialog', () => {
|
||||
it('a switched-off victory condition deals as 0, which is what the engine calls off', async () => {
|
||||
const { els, nav: n } = await load('?seed=430');
|
||||
(els.get('newgame')!['onclick'] as () => void)();
|
||||
const dlg = els.get('newgamedlg')!;
|
||||
els.get('ng-coltotal-on')!['checked'] = false;
|
||||
(els.get('ng-coltotal-on')!['onchange'] as () => void)();
|
||||
dlg['returnValue'] = 'deal';
|
||||
(dlg['close'] as () => void)();
|
||||
els.get('ss-coltotal-on')!['checked'] = false;
|
||||
(els.get('ss-coltotal-on')!['onchange'] as () => void)();
|
||||
(els.get('ss-deal')!['onclick'] as () => void)();
|
||||
assert.match(n.search, /coltotal=0/, 'unticking the total-collision condition did not switch it off');
|
||||
});
|
||||
|
||||
it('deals nothing on cancel, and nothing on Esc', async () => {
|
||||
// Esc closes a <dialog> with an empty returnValue and fires no submit at all, so "not deal" has
|
||||
// to be the test rather than "cancel" — the two arrive identically.
|
||||
for (const returnValue of ['cancel', '']) {
|
||||
const { els, nav: n } = await load('?seed=430');
|
||||
(els.get('newgame')!['onclick'] as () => void)();
|
||||
const dlg = els.get('newgamedlg')!;
|
||||
dlg['returnValue'] = returnValue;
|
||||
(dlg['close'] as () => void)();
|
||||
assert.equal(n.search, '?seed=430', `closing with "${returnValue}" navigated`);
|
||||
assert.equal(n.reloads, 0, `closing with "${returnValue}" reloaded`);
|
||||
}
|
||||
it('says the game has RESUMED, not begun, when you come back to one', async () => {
|
||||
/**
|
||||
* REPORTED BY JESSE 2026-08-30: "when you are continuing the saved game out of that screen, do
|
||||
* not post a message that says 'The game has begun.' … it needs to say 'The game has resumed.'"
|
||||
*
|
||||
* A restored game draws exactly like a dealt one — mid-Day, mid-phase, log already deep — so
|
||||
* nothing distinguished the two. Checked in both directions here: the wording itself, and that
|
||||
* the multiplayer line which DOES say "begun" cannot be said to somebody rejoining.
|
||||
*/
|
||||
const save = JSON.stringify({ seed: 12345, history: [] });
|
||||
const { els } = await load('', { 'station-master.save.v1': save });
|
||||
assert.match(String(els.get('announce')!['textContent']), /has resumed/, 'a restored game said nothing');
|
||||
assert.doesNotMatch(String(els.get('announce')!['textContent']), /has begun/, 'a restored game claimed to be new');
|
||||
|
||||
// A freshly dealt game must NOT claim to be resumed — the announcement has to mean something.
|
||||
const fresh = await load('?hand=sixRandom');
|
||||
assert.doesNotMatch(String(fresh.els.get('announce')?.['textContent'] ?? ''), /resumed/,
|
||||
'a brand new game announced itself as a resume');
|
||||
|
||||
// And the multiplayer first-frame line is conditional now rather than always "begun".
|
||||
const src = readFileSync(join(root, 'src/web/main.ts'), 'utf8');
|
||||
assert.match(src, /rejoining \? 'resumed' : 'begun'/, 'rejoining a table still says the game has begun');
|
||||
});
|
||||
|
||||
it('backing out deals nothing and puts the same game back on screen', async () => {
|
||||
/**
|
||||
* WHAT CANCEL USED TO BE. The dialog had a Cancel button and an Esc key, and this pinned that
|
||||
* neither dealt. The screen that replaced it has neither — it has "Continue Existing Saved
|
||||
* Game", which has to do the same job and one more besides: the game was never navigated away
|
||||
* from, so going back is showing the board again rather than reloading and replaying it.
|
||||
*/
|
||||
const { els, nav: n } = await load('?seed=430');
|
||||
(els.get('newgame')!['onclick'] as () => void)();
|
||||
assert.equal(els.get('ss-resume')!['hidden'], false, 'mid-game there is no way back to the game');
|
||||
|
||||
(els.get('ss-resume')!['onclick'] as () => void)();
|
||||
assert.equal(els.get('gameui')!['hidden'], false, 'backing out did not return to the board');
|
||||
assert.equal(els.get('solitairesetup')!['hidden'], true, 'the setup screen stayed up');
|
||||
assert.equal(n.search, '?seed=430', 'backing out navigated');
|
||||
assert.equal(n.reloads, 0, 'backing out reloaded, losing the game in memory');
|
||||
});
|
||||
|
||||
it('reloads when the answers are the URL the page already has, so a re-deal is not a no-op', async () => {
|
||||
// Dealing a random seed, disliking it and dealing again at the same settings produces the same
|
||||
// search string — and assigning `location.search` the value it already holds does nothing.
|
||||
const url =
|
||||
'?hand=sixRandom&extra=anyOffice&passenger=1&freight=1&transit=0&days=5&minrev=15&colday=3&coltotal=5';
|
||||
'?hand=sixRandom&extra=ownOffice&passenger=1&freight=1&transit=0&days=5&minrev=15&colday=3&coltotal=5';
|
||||
const { els, nav: n } = await load(url);
|
||||
(els.get('newgame')!['onclick'] as () => void)();
|
||||
const dlg = els.get('newgamedlg')!;
|
||||
dlg['returnValue'] = 'deal';
|
||||
(dlg['close'] as () => void)();
|
||||
(els.get('ss-deal')!['onclick'] as () => void)();
|
||||
|
||||
assert.equal(n.search, url, 'the URL should be unchanged — that is the whole case');
|
||||
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 () => {
|
||||
@@ -4289,14 +4764,12 @@ describe('the New Game dialog', () => {
|
||||
// optional box is not worth writing.
|
||||
const { els, nav: n } = await load('?seed=430');
|
||||
(els.get('newgame')!['onclick'] as () => void)();
|
||||
const dlg = els.get('newgamedlg')!;
|
||||
els.get('ng-seed')!['value'] = 'not a number';
|
||||
dlg['returnValue'] = 'deal';
|
||||
(dlg['close'] as () => void)();
|
||||
els.get('ss-seed')!['value'] = 'not a number';
|
||||
(els.get('ss-deal')!['onclick'] as () => void)();
|
||||
|
||||
assert.equal(
|
||||
n.search,
|
||||
'?hand=sixRandom&extra=anyOffice&passenger=1&freight=1&transit=0&days=5&minrev=15&colday=3&coltotal=5',
|
||||
'?hand=sixRandom&extra=ownOffice&passenger=1&freight=1&transit=0&days=5&minrev=15&colday=3&coltotal=5',
|
||||
'a bad seed was carried into the URL',
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user