Compare commits
9
Commits
193800a649
...
31b942cc38
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
31b942cc38 | ||
|
|
bb1b661211 | ||
|
|
788e5f2eec | ||
|
|
a70b7f88f3 | ||
|
|
131538dc7c | ||
|
|
cf018b4a5f | ||
|
|
45cf521a40 | ||
|
|
e035dda5a3 | ||
|
|
f2c87b6871 |
+254
@@ -19,6 +19,260 @@ 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.
|
||||||
|
|
||||||
|
### 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".
|
||||||
|
|
||||||
|
878 tests pass, eleven 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
|
## 0.7.8 — 2026-08-30
|
||||||
|
|
||||||
### The setup screen was unreachable for anyone who had ever played
|
### The setup screen was unreachable for anyone who had ever played
|
||||||
|
|||||||
@@ -5,302 +5,177 @@ not to — the point is that nothing quietly evaporates.
|
|||||||
|
|
||||||
Grouped by what kind of work it is — Next, Replay/Save Games, Bot Performance, Play Balance,
|
Grouped by what kind of work it is — Next, Replay/Save Games, Bot Performance, Play Balance,
|
||||||
Multiplayer, Display, Rules Questions, Other — and ordered within each by how much it is currently
|
Multiplayer, Display, Rules Questions, Other — and ordered within each by how much it is currently
|
||||||
costing us. **Display** was split out of Other on 2026-08-22, when a session at the board produced
|
costing us. **Next is the queue**; the other sections are where the reasoning lives, and Next points
|
||||||
seven items about the screen rather than the rules; the "most recent action" entry moved with it.
|
into them rather than repeating them.
|
||||||
Reorganized 2026-08-20 from a flat list; nothing below changed, only where it lives. Two duplicate
|
|
||||||
entries (Heavy Grade orientation, the Local's coach) were merged into one each, and the industry-table
|
**Reorganized 2026-08-30**, and the reason is worth recording because it will happen again. Nothing
|
||||||
item that had been sitting in a "these are all done" section without actually being done was moved out
|
was wrong with the ordering — what had gone wrong is that the file had stopped tracking its own
|
||||||
to Rules Questions.
|
closures. Next was a chronological list of 43 entries with 26 struck through, and eight items in
|
||||||
|
Display were still open `- [ ]` although Next already recorded them as superseded by Gitea#18 or
|
||||||
|
shipped in v0.7.9. **An item closed in one place and left open in another is worse than no entry at
|
||||||
|
all**, because it reads as live work. So: Next is now grouped by what each item is WAITING ON, the
|
||||||
|
closed entries moved to a "Settled" block at the end of it, and the five dead Division-map drawing
|
||||||
|
items collapsed into one that keeps only the two ideas which outlived them. 65 open items became 57.
|
||||||
|
|
||||||
|
Earlier passes, kept: **Display** was split out of Other on 2026-08-22, when a session at the board
|
||||||
|
produced seven items about the screen rather than the rules. Reorganized 2026-08-20 from a flat list.
|
||||||
|
Two duplicate entries (Heavy Grade orientation, the Local's coach) were merged into one each, and the
|
||||||
|
industry-table item that had been sitting in a "these are all done" section without actually being
|
||||||
|
done was moved out to Rules Questions.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Next
|
## Next
|
||||||
|
|
||||||
Queued from the 2026-08-20 multiplayer planning session (reasoning in Multiplayer below), in order:
|
**The live queue.** Every entry keeps the number it was given when it was raised — these are ids,
|
||||||
|
not positions, and the rest of this file refers to them by number ("decide with item 15", "same
|
||||||
|
drawing pass as 19-21"). Nothing is renumbered when something closes; settled entries move to
|
||||||
|
[Settled, kept for the reasoning](#settled-kept-for-the-reasoning) at the end of this section instead.
|
||||||
|
|
||||||
1. ~~**Fix the New Train phase car-placement round**~~ — done, see Multiplayer below.
|
Regrouped 2026-08-30 by **what each is waiting on**, which is the question actually asked of this
|
||||||
2. ~~**Unify victory conditions across solitaire, competitive and coop**~~ — done, see Multiplayer
|
list. It was a chronological queue of 43 entries, 26 of them struck through, and the live work had
|
||||||
below.
|
stopped being visible in it.
|
||||||
3. ~~**Phase 2 of `docs/architecture/multiplayer.md` — server core**~~ — done, see Multiplayer below.
|
|
||||||
|
|
||||||
Queued 2026-08-21, from playing the StartOS build:
|
### Blocked on a table, not on code
|
||||||
|
|
||||||
4. ~~**The lobby must offer every game parameter the solitaire New Game dialog does**~~ — done in
|
The largest gap in the project, and it is not a coding gap. Four features shipped without a person
|
||||||
v0.6.0.
|
ever meeting them.
|
||||||
5. ~~**Decide what the four `optionalRules` are**~~ — done in v0.6.0: `sisterTrains` deleted,
|
|
||||||
`employeeRotation` implemented, the other two were already live.
|
|
||||||
6. ~~**Stop every release destroying every game in progress**~~ — done in v0.6.0, by replaying the
|
|
||||||
save rather than comparing version strings.
|
|
||||||
|
|
||||||
Queued 2026-08-22, from playing on StartOS:
|
|
||||||
|
|
||||||
7. **Make "Games in Progress" readable** — **ON HOLD, 2026-08-29 (Jesse).** StartOS 0.4.0.2 is
|
|
||||||
expected to improve how action results are displayed, which is most of what makes this unreadable
|
|
||||||
— so wait and see what the platform fixes before rewriting the action around a limitation that
|
|
||||||
may be gone. Re-open it against 0.4.0.2 and re-read the output before designing anything.
|
|
||||||
Reasoning in Multiplayer below.
|
|
||||||
8. **Give a player a way back into a game after losing their browser** — a fresh browser is still
|
|
||||||
locked out of a RUNNING game, even though the server knows who they are. Reasoning in Multiplayer
|
|
||||||
below; needs Jesse's call on whether a token in a URL is acceptable. **The lobby half of this was
|
|
||||||
fixed 2026-08-23** — a reload while seated no longer orphans the chair, and a seat can now be
|
|
||||||
given up rather than wedging the table.
|
|
||||||
|
|
||||||
Queued 2026-08-22, from a v0.4.9d gameplay-testing report (six bugs, forwarded by Jesse):
|
|
||||||
|
|
||||||
9. ~~**Two trains in one station answer to one button**~~ — done in v0.4.9e / the release below.
|
|
||||||
10. ~~**A Freight House can unload the boxcar it just loaded; a platform can detrain the passengers
|
|
||||||
it just boarded**~~ — done in v0.4.9e / the release below.
|
|
||||||
11. ~~**The Grocer's Warehouse ships and the Refinery receives**~~ — done in v0.4.9e / the release
|
|
||||||
below: both are one-way again.
|
|
||||||
12. **PARTLY REPRODUCED: cars left behind when backing up over them** — see Rules Questions below.
|
|
||||||
Half of it turned out to be the second half of Gitea#17 and is fixed (2026-08-26): a train
|
|
||||||
pulling out through a 45° leg left its own cut standing. The "I can later drive right through
|
|
||||||
them" half is still unexplained and still needs a board from whoever filed it.
|
|
||||||
|
|
||||||
Queued 2026-08-22, from the v0.4.9e gameplay-testing report filed as Gitea issues.
|
|
||||||
|
|
||||||
12a. ~~**Gitea#4 — confirm that extra trains start properly**~~ — done in the release below. Either
|
|
||||||
Division Point, the Interchange with a direction chosen there, a Control Point gated by a new
|
|
||||||
`extraStart` house rule, and the Superintendent's hold on the way out of the Interchange yard.
|
|
||||||
Reasoning in `docs/rules/implications.md` §7.
|
|
||||||
|
|
||||||
12d. ~~**Gitea#7 — coach counts on four train cards**~~ — done in the release below. 1/2 Crack
|
|
||||||
Limited 3 coaches → 2, 5/6 The Sparrow 2 → 3. A change to the cards, so `Trains3.pdf` and the
|
|
||||||
transcription in `docs/rules/implications.md` §5 keep the original numbers with a footnote;
|
|
||||||
`src/engine/content.ts` and `docs/StationMaster-Home-Deck-v0.4.5.md` carry what the game plays.
|
|
||||||
|
|
||||||
12c. ~~**Gitea#6 — players may not discard train cards**~~ — done in the release below. Timetabled
|
|
||||||
and Extra alike; the forced play falls out of the hand limit rather than needing a mechanism of
|
|
||||||
its own. Reasoning in `docs/rules/implications.md` §6.2.
|
|
||||||
|
|
||||||
12b. ~~**Gitea#2 — four porters, two passengers on the platform, and only one may be worked**~~ —
|
|
||||||
RULED AND FIXED in the release below, though not the way the report implies. The engine is
|
|
||||||
faithful to the written rules at every step; what bites is that BOTH directions of porter work
|
|
||||||
move coaches one-way into a Classification Yard that comes back only when the Division Yard is
|
|
||||||
bare of all ~60 cars. Sixteen coaches in the game, and the reported save runs dry on Day 5 with
|
|
||||||
eight of them stranded in Classification. **Jesse's ruling: the shortage stays** — "it is
|
|
||||||
possible to run out, that's part of the strategy" — so the three balance options in Play Balance
|
|
||||||
below are declined, not deferred. What was actually wrong is that the game said NOTHING: a Porter
|
|
||||||
action that cannot be taken is simply absent from the menu, and the "why is nothing moving?"
|
|
||||||
panel covered freight facilities only. That half is fixed.
|
|
||||||
|
|
||||||
12e. ~~**Gitea#8 — the per-diem train could not couple a caboose**~~ — done in the release below.
|
|
||||||
`ROLLING_STOCK_SUPPLY` mints all six cabooses `loaded: true` because §2.2's "coloured is loaded,
|
|
||||||
white is empty" doubles as a PIECE COUNT there and there is no white caboose. X22 Pee-Dee, whose
|
|
||||||
whole card is "may only pick up MTs", read that literally and refused every caboose including the
|
|
||||||
one it was made up with — set it out and the train was stranded, which made it unplayable rather
|
|
||||||
than merely restricted. A caboose carries the crew, not freight, so it is never a load.
|
|
||||||
|
|
||||||
12g. ~~**Gitea#9 — a Timetabled train may be discarded**~~ — done in the release below, and it
|
|
||||||
SUPERSEDES Gitea#6 (item 12c above), shipped three days earlier. A Timetabled train may be tossed
|
|
||||||
face-up to a Department slot, where a rival may pick it up — which needed no machinery, since
|
|
||||||
that is where every discard already goes. An Extra still may not: it never joins the timetable,
|
|
||||||
so it can never be what jams it. On this line the Timetabled half is a New Game setting
|
|
||||||
(`discardTimetabled`, on by default), because Jesse's reasoning is about games run longer than
|
|
||||||
five Days; the 0.4.9 line takes the plain rule. Reasoning in `docs/rules/implications.md` §6.2.
|
|
||||||
|
|
||||||
12f. ~~**Gitea#10 — a dialog when the Day rolls over**~~ — done in the release below. "Hard to keep
|
|
||||||
track of time." Nothing on screen was wrong — the clock, the turn chart and the timetable all
|
|
||||||
said which Day it was — but a Day turns over inside the phases that run themselves, so it passes
|
|
||||||
between one click and the next, and the two transient signals the page had (the phase banner at
|
|
||||||
2.6s, the announcement flash at 4.2s) are both gone before a player reading the board notices.
|
|
||||||
A modal stops and waits, and carries the standings, the Days left and the combined target.
|
|
||||||
Suppressed on the first frame, on Undo stepping back across a rollover, and on the Day the game
|
|
||||||
ends — the outcome panel is the thing to read then.
|
|
||||||
|
|
||||||
13. **Watch the other players and the bots actually make their moves** — raised by Jesse
|
|
||||||
2026-08-22, and **settled 2026-08-29 as the harder of the two readings**: "I want to be able to
|
|
||||||
watch other players and bots make their moves. It's not fun to do my turn and have magic happen
|
|
||||||
in the background and then have to figure out what others did."
|
|
||||||
|
|
||||||
So this is not the log-legibility fix. It is the ordered, per-action presentation of everyone
|
|
||||||
else's turns — **the same mechanism Gitea#20 step 4 specifies for the common board**, routed to a
|
|
||||||
player's own screen as well. Jesse: "this relates to issue #20 and will require a lot more
|
|
||||||
thinking." Do not start it as a standalone piece; it wants designing with #20. Reasoning in
|
|
||||||
Multiplayer below.
|
|
||||||
|
|
||||||
14. **INVESTIGATE: stamp the history with wall-clock time** — even if nothing displays it yet, so
|
|
||||||
"how long did that turn take" can be answered afterwards. Reasoning in Replay / Save Games below.
|
|
||||||
Half of it already exists server-side and is read by nothing.
|
|
||||||
|
|
||||||
15. **INVESTIGATE: a "most recent action" line under the status block** — above the Division map,
|
|
||||||
saying what just happened in the same words the history uses. Reasoning in Display below;
|
|
||||||
overlaps item 13 and should be decided with it.
|
|
||||||
|
|
||||||
15a. **Build documentation FROM the implementation, starting with a card reference** — raised by
|
|
||||||
Jesse 2026-08-22. Reasoning in Other below. The prompt for it was finding train card data spread
|
|
||||||
across five documents of three different vintages, one of them superseded.
|
|
||||||
|
|
||||||
Queued 2026-08-22, from a session looking at the screen rather than the rules. All Display below.
|
|
||||||
|
|
||||||
16. **Three explicit display options for the Office map** — always hidden, always on, auto-hide. All
|
|
||||||
three modes already exist; only the BUTTON is a cycle, and it cannot reach every one of them.
|
|
||||||
17. **The same three options for the Division map**, which today cannot be hidden at all. Auto there
|
|
||||||
means something different and useful: hide it now, bring it back at the end of the phase.
|
|
||||||
18. **Give every phase a visible beat.** The automatic phases are not too fast — they are never
|
|
||||||
drawn at all, because `pump` runs them all before the page renders once.
|
|
||||||
19. ~~**Turn the track art vertical on a Division card laid vertically**~~ — **SUPERSEDED by
|
|
||||||
Gitea#18** (Jesse, 2026-08-26). There are no vertical lanes any more: the Division draws as a
|
|
||||||
single row, west to east.
|
|
||||||
20. ~~**Run the inter-row connector round the OUTSIDE**~~ — **SUPERSEDED by Gitea#18.** No second
|
|
||||||
row, so nothing to connect.
|
|
||||||
21. ~~**The Division Point captions overflow the map**, and the buffer stops point the wrong way once
|
|
||||||
the route wraps~~ — **SUPERSEDED by Gitea#18.** Nothing wraps; both ends face outward.
|
|
||||||
22. ~~**Fill the dead centre of the Division map with the common board**~~ — **SUPERSEDED by
|
|
||||||
Gitea#18.** A row has no centre to fill.
|
|
||||||
23. **History: newest at the top?** Plus the timestamps question from item 14, which lands here.
|
|
||||||
24. ~~**Put the viewer's own district at the BOTTOM of the Division map** and wrap the table around
|
|
||||||
them~~ — **DEFINITIVELY SUPERSEDED by Gitea#18** (Jesse's word, 2026-08-26). A single row and a
|
|
||||||
table wrapped around the viewer cannot both hold, and the row wins: being able to rely on east
|
|
||||||
meaning right is worth more than being seated at the table.
|
|
||||||
|
|
||||||
Queued 2026-08-23, from Jesse playing the v0.7.0 build on StartOS. **All three were the same drawing
|
|
||||||
pass as 19-21 and 24 above, and all three are answered by Gitea#18 rather than fixed:**
|
|
||||||
|
|
||||||
25. ~~**The Division map does not draw track geometry at all**~~ — **SUPERSEDED by Gitea#18.** The
|
|
||||||
Division map stops drawing office-area detail altogether, so there is no Running Track on it to
|
|
||||||
draw geometry for. The geometry belongs to the Office map, which already draws it.
|
|
||||||
26. ~~**CONFIRMED IN PLAY: the buffer stop points the wrong way** at two players~~ — **SUPERSEDED by
|
|
||||||
Gitea#18**, with item 21.
|
|
||||||
27. ~~**CONFIRMED IN PLAY: east is not always to the right.**~~ — **FIXED OUTRIGHT by Gitea#18**, and
|
|
||||||
the reason it wins over item 24. One row means east is always to the right.
|
|
||||||
|
|
||||||
28. **INVESTIGATE: move the game's settings off the top line and into a card of their own** — and
|
|
||||||
show ALL of them, not the four that fit. Reasoning in Display below.
|
|
||||||
|
|
||||||
29. **Put the Fedora at the right-hand end of the phase row**, with (or in) the Supervisor Shift
|
|
||||||
pill. Reasoning in Display below.
|
|
||||||
|
|
||||||
Queued 2026-08-25, from releasing 0.7.1 / 0.4.9g. **Both are blocked on the two commits being made
|
|
||||||
and pushed** — they were still uncommitted when the session ended.
|
|
||||||
|
|
||||||
30. ~~**Close the Gitea issues by hand, each with a comment naming the commit that fixed it.**~~ —
|
|
||||||
done, and done again for v0.7.2 / v0.4.9h (2026-08-26). #2, #6, #8, #9 and #10 carry their
|
|
||||||
comments from the v0.7.1 release, including the pointer on #6 saying #9 superseded it. #3, #14,
|
|
||||||
#15, #17 and #18 now carry theirs, each naming `2ab25e3` and `e9683cc` and the version each
|
|
||||||
shipped in — and, where the fix was not what the report implied, the ruling that decided it:
|
|
||||||
#15 was REVERSED on review (the placement is legal; what was confirmed is that no train crosses
|
|
||||||
the gap), and #14's comment lists the ten unbuilt cards that are held out, so they do not vanish
|
|
||||||
along with the issue.
|
|
||||||
|
|
||||||
**Keep doing this.** Auto-closing leaves an issue with no record of which commit or which release
|
|
||||||
answered it. The token and the API calls are in the workspace's `AGENTS.local.md`.
|
|
||||||
|
|
||||||
31. ~~**Bump the StartOS wrapper to 0.7.2.**~~ — done 2026-08-26 (`1bfea8d`). Submodule pinned to
|
|
||||||
`v0.7.2`, `current.ts` at `0.7.2:0`, release notes rewritten in all five locales, `README.md` and
|
|
||||||
`instructions.md` updated. No new version file and no migration. Verified: `npm run check` clean
|
|
||||||
and `make x86` packs as `v0.7.2:0`.
|
|
||||||
|
|
||||||
**This is the first release that does NOT carry games in progress**, and the release notes lead
|
|
||||||
with it in every locale. A save is a seed plus the moves played; the deck going from 206 cards to
|
|
||||||
121 means a card id recorded under 0.7.1 refers to a different card or to none, so a save stops
|
|
||||||
replaying at its first `card.play`. There is nothing to migrate — those moves were made against a
|
|
||||||
deck that no longer exists — and it fails safe: `src/server/index.ts` refuses to resume a save the
|
|
||||||
rules reject, names the move it stopped at, and leaves the file untouched, so an operator can put
|
|
||||||
0.7.1 back on to finish a game that matters.
|
|
||||||
|
|
||||||
32. **Tell the 0.4.9 playtesters their saves are dead, before they find out.** The same deck change
|
|
||||||
shipped as v0.4.9h, so every save filed before it — including the ones attached to Gitea#15 and
|
|
||||||
#17 — stops replaying at its first `card.play`, and any game a tester has in progress will be
|
|
||||||
declined on restart. They fail safe and the files are kept, but nobody has been told. Worth a
|
|
||||||
line wherever the tester build is announced, and worth knowing when the next bug report arrives
|
|
||||||
with a save that will not load.
|
|
||||||
|
|
||||||
Queued 2026-08-29, from building Gitea#11 and #16 (both shipped in v0.7.3, main only):
|
|
||||||
|
|
||||||
33. **The second pass on the results screen — badges, and the brainstorm Gitea#16 asks for.** The
|
|
||||||
first pass is in and reports everything the Frame and the event tally know. What it deliberately
|
|
||||||
does not have is the interesting half: "maybe create badges for anything interesting that
|
|
||||||
happened… there should be a whole conversation brainstorming session on what are the things that
|
|
||||||
might be interesting for people to be aware of at the end of the game." The raw material is
|
|
||||||
already being kept — `tally.trainsCompletedWithWork` is the switching-master join, `longestStand`
|
|
||||||
is the engine that sat on a siding — and because the statistics are DERIVED from the event stream
|
|
||||||
rather than recorded, a second pass can add any of them retroactively to games already played and
|
|
||||||
saved. Needs Jesse and a conversation, not code, to start.
|
|
||||||
|
|
||||||
34. **`replay.ts` prints a raw outcome enum, exactly as the results screen used to.** Its summary
|
|
||||||
line is `` `${o.result} — ${o.reason}` ``, which renders "loss — revenueFloor" — the same defect
|
|
||||||
Gitea#16 was filed about, in the dev-side replay viewer rather than the playable page. The
|
|
||||||
sentences now exist (`panels.ts`'s `reasonSentence`), but they are written against a `Frame` and
|
|
||||||
the replay recorder has a `GameState`, so it is a small refactor rather than a one-line swap. Not
|
|
||||||
done in v0.7.3 because nothing about it is player-facing and the change earns its own look.
|
|
||||||
|
|
||||||
35. **Extended play has never been played at a real table** — but it now works on a real server.
|
|
||||||
**Verified live on phoenix.local, 2026-08-29**, against the installed v0.7.3:0 rather than in
|
|
||||||
tests: a two-seat competitive game (one human client, one bot) was dealt over the HTTP API with
|
|
||||||
`days: 1`, played to the end of its timetable, and reached `awaitingExtension` on Day 2 with
|
|
||||||
`official = { win, winner 0, daysElapsed }` frozen at Day 1 (`config.days`) and votes
|
|
||||||
`[null, null]`. Voting yes as seat 0 was accepted, the bot followed as designed, and the game
|
|
||||||
returned to `active` with `extraDays: 1` and the official outcome **unchanged**. The tally rode
|
|
||||||
the Frame to the client. Both test games were deleted afterwards; the box is back to Jesse's own
|
|
||||||
`WHISTLE-4086` and the `FREIGHT-3230` lobby.
|
|
||||||
|
|
||||||
**The save carry-over claim was also checked rather than asserted**: phoenix held five saves
|
|
||||||
before the update, of which `WHISTLE-4086` resumed and three were already refused by the 0.7.2
|
|
||||||
deck change. After updating to 0.7.3 the log is identical — same game resumed with the same 7
|
|
||||||
intents, same three refusals at the same move with the same code.
|
|
||||||
|
|
||||||
**What is still untested is the part the item is named for: humans, at a table.** Nobody has sat
|
|
||||||
down and played a game off the end of its timetable, and the multiplayer vote has never been
|
|
||||||
driven through two browsers — what a second player sees while waiting on a first, and whether
|
|
||||||
"waiting on Carol" is legible once Carol has closed her laptop, are still unanswered. Worth being
|
|
||||||
the first thing the next play session does.
|
|
||||||
|
|
||||||
36. **There is no per-Stage "this train did not move" signal, so "longest an engine sat on a siding"
|
|
||||||
cannot be answered.** Gitea#16 asks for it and the comment on that issue said `trainStoodStill`
|
|
||||||
would supply it, "emitted per Stage, so a run of them is exactly the streak you describe". That
|
|
||||||
is wrong, and was found only by reading `advance.ts` while building the tally: the event fires
|
|
||||||
for a train whose profile sets `stopEarnsPoint` — the X18 Circus and nothing else — and
|
|
||||||
`tray.stopPointClaimed` guarantees it fires at most once per train per game. A streak folded from
|
|
||||||
it reads "1 Stage" for ever, which is what v0.7.3 built and then removed.
|
|
||||||
**What it would take:** either a new event emitted per Stage per stationary tray (cheap to emit,
|
|
||||||
but it is a lot of events for a statistic nothing scores), or sampling live state on the Stage
|
|
||||||
boundary the way `stats.ts`'s funnel probe does — which the tally cannot do today, because it
|
|
||||||
folds a batch of events AFTER `advance` has already mutated past the moment they describe. Worth
|
|
||||||
settling with the badge pass (#33) rather than on its own, since that is the only consumer.
|
|
||||||
|
|
||||||
37. ~~**Bump the StartOS wrapper to 0.7.3, then 0.7.4.**~~ — 0.7.3 done 2026-08-29 (`74aea24` in
|
|
||||||
`station-master-startos`). Submodule pinned to `v0.7.3` (`45580d8`), `current.ts` at `0.7.3:0`,
|
|
||||||
release notes in all five locales, `README.md` and `instructions.md` updated. No new version file
|
|
||||||
and no migration — the outgoing `0.7.2:0`'s `up` was empty, `versions.md`'s common case, so
|
|
||||||
`current.ts` bumped in place. Verified: `npm run check` clean, prettier clean, `make x86` packs
|
|
||||||
as `v0.7.3:0`. **NOT installed on a box and not played** — see #35.
|
|
||||||
|
|
||||||
**Unlike 0.7.2, games in progress survive this one**, and both docs lead with it. No card data
|
|
||||||
changed and the engine changes are additive, so every intent in a 0.7.2 save is still legal;
|
|
||||||
proven rather than assumed by `test/harness.test.ts`, which replays the three files in
|
|
||||||
`public/replays` (all recorded under an older ruleset) and asserts every intent still applies.
|
|
||||||
|
|
||||||
**Bumped again to `0.7.4:0` the same day** (`085b88b`), pinned to `v0.7.4`, and installed on
|
|
||||||
`phoenix.local` — verified there, not merely packed: the resume log shows `WHISTLE-4086` coming
|
|
||||||
back with its 7 intents and all five new engine code paths present in the served build. Both tags
|
|
||||||
are signed and pushed.
|
|
||||||
|
|
||||||
**Keep doing the whole sequence.** Tag the app, fetch the tag into the wrapper's submodule,
|
|
||||||
bump `current.ts` in place (the outgoing `up` has been empty every time, `versions.md`'s common
|
|
||||||
case), rewrite the notes in all five locales, update `README.md` and `instructions.md`, then
|
|
||||||
`npm run check` / prettier / `make x86` / `make install`. `UPDATING.md` in the wrapper is the
|
|
||||||
authority and has not needed changing.
|
|
||||||
|
|
||||||
38. ~~**Gitea#13, #5 and #19 — three rules corrections.**~~ — done 2026-08-29 in v0.7.4
|
|
||||||
(`5e34c73`, `2280276`, `19a6a47`), wrapper `085b88b` as `0.7.4:0`, installed on `phoenix.local`.
|
|
||||||
Each issue carries a comment naming its commit and what was ruled, per #30. Reasoning is in
|
|
||||||
`CHANGELOG.md`; what matters here is what they left behind, below.
|
|
||||||
|
|
||||||
39. **NONE OF v0.7.4 HAS BEEN PLAYED BY A HUMAN.** The Yard Office offer, the Red Flag hold and its
|
39. **NONE OF v0.7.4 HAS BEEN PLAYED BY A HUMAN.** The Yard Office offer, the Red Flag hold and its
|
||||||
out-of-phase prompt, and the loaded-Extra make-up rules are all tested end to end, packed, and
|
out-of-phase prompt, and the loaded-Extra make-up rules are all tested end to end, packed, and
|
||||||
running on `phoenix.local` — and no person has met any of them at a board. Two are interruptions
|
running on `phoenix.local` — and no person has met any of them at a board. Two are interruptions
|
||||||
that stop the Mainline Phase and put a question in front of somebody mid-thought, which is
|
that stop the Mainline Phase and put a question in front of somebody mid-thought, which is
|
||||||
exactly the kind of thing only play reveals. Together with #35 this is now the biggest gap in the
|
exactly the kind of thing only play reveals.
|
||||||
project: four features shipped without a table.
|
|
||||||
|
|
||||||
40. **A save from before v0.7.4 may not replay, and nobody has been told.** The same shape as #32 but
|
35. **Extended play has never been played at a real table.** **Verified live on phoenix.local,
|
||||||
for the main line: the Red Flags intent changed shape, a make-up that was legal may now be
|
2026-08-29**, against the installed v0.7.3:0 rather than in tests: a two-seat competitive game
|
||||||
refused, and a Yard Office arrival asks a question no older history has an answer for. It fails
|
(one human client, one bot) was dealt over the HTTP API with `days: 1`, played to the end of its
|
||||||
safe — the server declines the save, names the move and leaves the file untouched — and
|
timetable, and reached `awaitingExtension` on Day 2 with `official = { win, winner 0,
|
||||||
`WHISTLE-4086` did survive on `phoenix.local`, so it is "may not" rather than "will not". Worth a
|
daysElapsed }` frozen at Day 1 and votes `[null, null]`. Voting yes as seat 0 was accepted, the
|
||||||
line wherever the build is announced, and worth knowing when a bug report arrives with a save
|
bot followed as designed, and the game returned to `active` with `extraDays: 1` and the official
|
||||||
that will not load.
|
outcome **unchanged**. Both test games were deleted afterwards.
|
||||||
|
|
||||||
|
**The save carry-over claim was checked rather than asserted**: phoenix held five saves before
|
||||||
|
the update, of which `WHISTLE-4086` resumed and three were already refused by the 0.7.2 deck
|
||||||
|
change. After updating to 0.7.3 the log is identical — same game resumed with the same 7 intents,
|
||||||
|
same three refusals at the same move with the same code.
|
||||||
|
|
||||||
|
**What is still untested is what the item is named for: humans, at a table.** The multiplayer
|
||||||
|
vote has never been driven through two browsers — what a second player sees while waiting on a
|
||||||
|
first, and whether "waiting on Carol" is legible once Carol has closed her laptop, are still
|
||||||
|
unanswered.
|
||||||
|
|
||||||
|
**A HUMAN DID REACH IT ON 2026-08-30, AND IT WAS UNUSABLE — fixed in v0.7.9.** Jesse played a
|
||||||
|
solitaire game to the end and was never offered the extra Day. Nothing was wrong with the engine
|
||||||
|
or the Frame: `renderEnding` wrote the two buttons into `#actions` and then opened `#resultsdlg`,
|
||||||
|
which is **modal**, so the question sat underneath a dialog whose only control was Close. The
|
||||||
|
dialog asks it now. **The verification recorded above is exactly why this survived** — it was
|
||||||
|
driven over the HTTP API, which renders no dialog, so what was proven was that the SERVER
|
||||||
|
supports extended play, not that a player can reach it. Read that distinction into every
|
||||||
|
"verified on phoenix.local" line in this file.
|
||||||
|
|
||||||
|
42a. **Nobody has clicked through the solitaire setup screen's own fields** and confirmed the dealt
|
||||||
|
game matches what was chosen. The screen took three attempts to become reachable at all (item 42
|
||||||
|
below); reachable is not the same as correct. Early item for the next play session, with #39.
|
||||||
|
|
||||||
|
40. **A save from before v0.7.4 may not replay, and nobody has been told.** The Red Flags intent
|
||||||
|
changed shape, a make-up that was legal may now be refused, and a Yard Office arrival asks a
|
||||||
|
question no older history has an answer for. It fails safe — the server declines the save, names
|
||||||
|
the move and leaves the file untouched — and `WHISTLE-4086` did survive on `phoenix.local`, so it
|
||||||
|
is "may not" rather than "will not". Worth a line wherever the build is announced, and worth
|
||||||
|
knowing when a bug report arrives with a save that will not load.
|
||||||
|
|
||||||
|
32. **Tell the 0.4.9 playtesters their saves are dead, before they find out.** The same shape as #40
|
||||||
|
but for the playtest line: the deck change shipped as v0.4.9h, so every save filed before it —
|
||||||
|
including the ones attached to Gitea#15 and #17 — stops replaying at its first `card.play`. They
|
||||||
|
fail safe and the files are kept, but nobody has been told. `PLAYTEST-0.7.4.md` (untracked, at
|
||||||
|
the repo root) is the note drafted for this and leads with it.
|
||||||
|
|
||||||
|
### The screen — v0.8.0 candidates
|
||||||
|
|
||||||
|
All six were raised by Jesse from play and all six live in **Display** below, where the reasoning is.
|
||||||
|
Listed here in rough increasing cost. Two of them entangle with Gitea#20; see the note under each.
|
||||||
|
|
||||||
|
28. **The game's settings belong in a card, not along the top line** — and show ALL of them, not the
|
||||||
|
four that fit. **Cheapest of the six**: nothing new has to be sent (`Frame` gained `mode` and
|
||||||
|
`optionalRules` in v0.7.0) and the renderer already exists — `rulesListHtml` (`settings-form.ts`)
|
||||||
|
draws exactly this list for the join preview and the seating screen.
|
||||||
|
|
||||||
|
16. **Three explicit display options for the Office map** — always hidden, always on, auto-hide. All
|
||||||
|
three modes already exist (`districtMode`, persisted); only the BUTTON is a cycle, and it cannot
|
||||||
|
reach every one of them.
|
||||||
|
|
||||||
|
17. **The same three options for the Division map**, which today cannot be hidden at all. `auto`
|
||||||
|
there means something different and useful — hide it now, bring it back at the end of the phase —
|
||||||
|
so it is a one-shot with an expiry rather than a standing rule, and probably should not share an
|
||||||
|
implementation with #16.
|
||||||
|
|
||||||
|
23. **History: newest at the top?** Reversing is nearly free; the cap (`slice(-60)`) and the forward-
|
||||||
|
reading phase headings are not. Carries the timestamps question from #14. **Weaker if #15 lands.**
|
||||||
|
|
||||||
|
15. **INVESTIGATE: a "most recent action" line under the status block.** The text already exists and
|
||||||
|
is already correct — this is placement, not content. **Overlaps #13 and should be decided with
|
||||||
|
it**: in solitaire "most recent" is the right unit, but in multiplayer what you missed is
|
||||||
|
everything that happened while you were WAITING, and one line showing only the last of them may
|
||||||
|
be the least useful line on the page.
|
||||||
|
|
||||||
|
18. **Give every phase a visible beat.** The automatic phases are not too fast — they are never drawn
|
||||||
|
at all, because `pump` runs them all before the page renders once. **So this is not a `sleep`: it
|
||||||
|
needs an async pump that steps `advance()` one call at a time and renders between — which is the
|
||||||
|
same machinery Gitea#20 step 4 specifies.** Whichever is built first should be built for both.
|
||||||
|
|
||||||
|
### Waiting on something outside the code
|
||||||
|
|
||||||
|
7. **Make "Games in Progress" readable** — **ON HOLD, 2026-08-29 (Jesse).** StartOS 0.4.0.2 is
|
||||||
|
expected to improve how action results are displayed, which is most of what makes this unreadable
|
||||||
|
— so wait and see what the platform fixes before rewriting the action around a limitation that may
|
||||||
|
be gone. Re-open it against 0.4.0.2 and re-read the output before designing anything.
|
||||||
|
|
||||||
|
8. **Give a player a way back into a game after losing their browser** — a fresh browser is still
|
||||||
|
locked out of a RUNNING game, even though the server knows who they are. **Needs Jesse's call on
|
||||||
|
whether a token in a URL is acceptable.** The lobby half was fixed 2026-08-23: a reload while
|
||||||
|
seated no longer orphans the chair, and a seat can be given up rather than wedging the table.
|
||||||
|
|
||||||
|
13. **Watch the other players and the bots actually make their moves.** Settled 2026-08-29 as the
|
||||||
|
harder of the two readings — Jesse: "It's not fun to do my turn and have magic happen in the
|
||||||
|
background and then have to figure out what others did." So it is not the log-legibility fix. It
|
||||||
|
is the ordered, per-action presentation of everyone else's turns — **the same mechanism Gitea#20
|
||||||
|
step 4 specifies for the common board**, routed to a player's own screen as well. Jesse: "this
|
||||||
|
relates to issue #20 and will require a lot more thinking." **Do not start it standalone.**
|
||||||
|
|
||||||
|
33. **The second pass on the results screen — badges, and the brainstorm Gitea#16 asks for.**
|
||||||
|
**Needs Jesse and a conversation, not code, to start.** The first pass is in and reports
|
||||||
|
everything the Frame and the event tally know; what it deliberately does not have is the
|
||||||
|
interesting half. The raw material is already kept — `tally.trainsCompletedWithWork` is the
|
||||||
|
switching-master join, `longestStand` is the engine that sat on a siding — and because the
|
||||||
|
statistics are DERIVED from the event stream rather than recorded, a second pass can add any of
|
||||||
|
them retroactively to games already played and saved.
|
||||||
|
|
||||||
|
36. **There is no per-Stage "this train did not move" signal, so "longest an engine sat on a siding"
|
||||||
|
cannot be answered.** The comment on Gitea#16 said `trainStoodStill` would supply it. That is
|
||||||
|
wrong, and was found by reading `advance.ts`: the event fires only for a train whose profile sets
|
||||||
|
`stopEarnsPoint` — the X18 Circus and nothing else — and `tray.stopPointClaimed` guarantees it
|
||||||
|
fires at most once per train per game, so a streak folded from it reads "1 Stage" for ever.
|
||||||
|
**What it would take:** a new event per Stage per stationary tray (cheap to emit, a lot of events
|
||||||
|
for a statistic nothing scores), or sampling live state on the Stage boundary the way `stats.ts`'s
|
||||||
|
funnel probe does — which the tally cannot do today, because it folds a batch of events AFTER
|
||||||
|
`advance` has already mutated past the moment they describe. **Settle with #33**, its only
|
||||||
|
consumer.
|
||||||
|
|
||||||
|
### Unscheduled
|
||||||
|
|
||||||
|
12. **PARTLY REPRODUCED: cars left behind when backing up over them.** Half of it turned out to be
|
||||||
|
the second half of Gitea#17 and is fixed (2026-08-26): a train pulling out through a 45° leg left
|
||||||
|
its own cut standing. **The "I can later drive right through them" half is still unexplained and
|
||||||
|
still needs a board from whoever filed it.** See Rules Questions below.
|
||||||
|
|
||||||
|
14. **INVESTIGATE: stamp the history with wall-clock time** — even if nothing displays it yet.
|
||||||
|
Reasoning in Replay / Save Games below. **Half of it already exists server-side and is read by
|
||||||
|
nothing**, so the first job is to look at a real game's timings file rather than to build.
|
||||||
|
|
||||||
|
15a. **Build documentation FROM the implementation, starting with a card reference.** Raised by Jesse
|
||||||
|
2026-08-22; reasoning in Other below. The prompt was finding train card data spread across five
|
||||||
|
documents of three different vintages, one of them superseded.
|
||||||
|
|
||||||
41. **The bot cannot use the half of Red Flags a human would.** It takes the danger prompt
|
41. **The bot cannot use the half of Red Flags a human would.** It takes the danger prompt
|
||||||
unconditionally and still plays zero flags in 200 games, because the prompt needs a colliding
|
unconditionally and still plays zero flags in 200 games, because the prompt needs a colliding
|
||||||
@@ -308,47 +183,124 @@ Queued 2026-08-29, from building Gitea#11 and #16 (both shipped in v0.7.3, main
|
|||||||
Stage for switching, which needs it to know it wants time — a notion it does not have. Reasoning
|
Stage for switching, which needs it to know it wants time — a notion it does not have. Reasoning
|
||||||
and the measurement are under Bot Performance.
|
and the measurement are under Bot Performance.
|
||||||
|
|
||||||
|
### Standing practice — not items, rules for every release
|
||||||
|
|
||||||
|
30. **Close each Gitea issue by hand, with a comment naming the commit that fixed it** and, where the
|
||||||
|
fix was not what the report implied, the ruling that decided it. Auto-closing leaves an issue with
|
||||||
|
no record of which commit or which release answered it. Done for every issue closed so far: #2,
|
||||||
|
#6, #8, #9 and #10 from v0.7.1; #3, #14, #15, #17 and #18 from v0.7.2 — including #15's REVERSAL
|
||||||
|
on review (the placement is legal; what was confirmed is that no train crosses the gap) and #14's
|
||||||
|
list of the ten unbuilt cards, so they do not vanish with the issue. The token and the API calls
|
||||||
|
are in the workspace's `AGENTS.local.md`.
|
||||||
|
|
||||||
|
37. **The wrapper release sequence, unchanged since 0.7.2 and worth following exactly.** Tag the app,
|
||||||
|
fetch the tag into the wrapper's submodule, bump `current.ts` **in place** (the outgoing `up` has
|
||||||
|
been empty every time, `versions.md`'s common case — no new version file, no migration), rewrite
|
||||||
|
the release notes in all five locales, update `README.md` and `instructions.md`, then
|
||||||
|
`npm run check` / prettier / `make x86` / `make install`. `UPDATING.md` in the wrapper is the
|
||||||
|
authority and has not needed changing.
|
||||||
|
|
||||||
|
**Say which way games in progress go, in every locale, every time.** 0.7.2 broke them (206 cards
|
||||||
|
to 121, so a card id recorded under 0.7.1 refers to a different card or to none) and led with it;
|
||||||
|
0.7.3 and 0.7.4 carried them and led with that. It fails safe either way — `src/server/index.ts`
|
||||||
|
refuses a save the rules reject, names the move it stopped at, and leaves the file untouched, so
|
||||||
|
an operator can put the old version back to finish a game that matters.
|
||||||
|
|
||||||
|
### Settled, kept for the reasoning
|
||||||
|
|
||||||
|
Closed items, newest first. Kept because several of them are the only record of a ruling or a lesson;
|
||||||
|
the numbers stay so cross-references above and below still resolve.
|
||||||
|
|
||||||
|
43. ~~**"Waiting on" said nobody while the game was stopped on the Superintendent.**~~ — done
|
||||||
|
2026-08-30 in v0.7.9. `Frame.actor` carried `clock.currentActor`, null for the whole Mainline
|
||||||
|
Phase, so all three interruptions (clearance, Yard Office, Red Flag) reported that the Division
|
||||||
|
was running itself. It carries `actingPlayer` now and a new `awaiting` field says what the
|
||||||
|
question is and which train it is about. **Worth knowing for the next Frame field:** the engine
|
||||||
|
had the right answer in `actingPlayer` since the Gitea#5 refactor, and the Frame simply did not
|
||||||
|
carry it. A view that reads one field of `clock` directly is a place this can happen again.
|
||||||
|
|
||||||
|
34. ~~**`replay.ts` printed a raw outcome enum, exactly as the results screen used to.**~~ — done
|
||||||
|
2026-08-30 in v0.7.9. Its summary line rendered "loss — revenueFloor", the same defect Gitea#16
|
||||||
|
was filed about, alive in the dev-side viewer a release after the playable page was fixed.
|
||||||
|
`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; the drift test maps `win`/`loss`
|
||||||
|
to `won`/`lost` so it still checks agreement rather than spelling.
|
||||||
|
|
||||||
|
29. ~~**Put the Fedora at the right-hand end of the phase row.**~~ — done 2026-08-30 in v0.7.9.
|
||||||
|
Details, and why the name does NOT go inside the Supervisor Shift pill, in Display below.
|
||||||
|
|
||||||
42. ~~**Solitaire must ask before it deals, the same way multiplayer's lobby already does.**~~ — done
|
42. ~~**Solitaire must ask before it deals, the same way multiplayer's lobby already does.**~~ — done
|
||||||
2026-08-29 in v0.7.5. Jesse: "let the user choose their options like the start of a multiplayer
|
2026-08-29 in v0.7.5, and then fixed three more times. Jesse: "let the user choose their options
|
||||||
game"; "asking first is the only path." A new `#solitairesetup` screen in `play.html` asks the
|
like the start of a multiplayer game"; "asking first is the only path." A `#solitairesetup` screen
|
||||||
full shared block — game type, starting hand, Extra start, revenue, victory conditions, optional
|
asks the full shared block before a genuinely fresh visit deals anything; a saved game, an
|
||||||
rules — before a genuinely fresh visit deals anything; a saved game, an explicit `?seed=`, or a
|
explicit `?seed=`, or a URL a Deal already wrote all skip past it. The in-game dialog, the lobby
|
||||||
URL a Deal already wrote all skip past it. The in-game dialog, the lobby and this screen now
|
and this screen share one `wireGameTypeBlock()`/`commitNewGame()` pair.
|
||||||
share one `wireGameTypeBlock()`/`commitNewGame()` pair instead of the dialog carrying its own
|
|
||||||
copy. Reasoning in `CHANGELOG.md`.
|
|
||||||
|
|
||||||
**Played in a browser on `phoenix.local` 2026-08-29, and it found a real bug — fixed same day in
|
**THE LESSON, AND IT IS THE MOST EXPENSIVE ONE IN THIS FILE.** The same report came back three
|
||||||
v0.7.6.** The splash's "Play solitaire" door landed straight in a leftover Co-op four-seat lobby
|
times, and each of the first two "fixes" corrected something real that was not the reported fault.
|
||||||
instead of the new setup screen: `start()` checked a browser-remembered multiplayer session
|
|
||||||
before ever looking at solitaire's own state, and a bare `./play.html` load could not tell "I
|
|
||||||
clicked Play solitaire" apart from "I reloaded mid multiplayer game" — the same class of problem
|
|
||||||
`?lobby` already solved for the door on the other side (D11), just never applied to this one. The
|
|
||||||
door now marks its intent (`?solitaire`), checked ahead of the remembered-session lookup.
|
|
||||||
|
|
||||||
**And it happened AGAIN on v0.7.6, which is what found the real cause — fixed in v0.7.7.** Every
|
- **v0.7.6** — the splash's "Play solitaire" door landed in a leftover Co-op lobby: `start()`
|
||||||
packaged build published the same cache-bust key (`?v=nogit`, because the `.s9pk` build has no
|
checked a remembered multiplayer session before looking at solitaire's own state. Real bug.
|
||||||
`.git` for `git rev-parse`), and the server sent no `Cache-Control` at all, so neither release
|
Not the one reported.
|
||||||
ever reached the browser that asked for it. Both earlier fixes were correct and both were
|
- **v0.7.7** — every packaged build published the same cache-bust key (`?v=nogit`, because the
|
||||||
verified by reading what the SERVER served — which was true and was never the thing in doubt.
|
`.s9pk` build has no `.git` for `git rev-parse`) and the server sent no `Cache-Control`, so
|
||||||
**The lesson worth keeping: when a fix appears to have had no effect, check that it arrived
|
neither release ever reached the browser that asked for it. Real bug. Still not the one
|
||||||
before re-diagnosing it.** A hard-reload would have answered it on the first report.
|
reported. **When a fix appears to have had no effect, check that it ARRIVED before
|
||||||
|
re-diagnosing it** — a hard reload would have answered it on the first report.
|
||||||
|
- **v0.7.8** — the actual fault: v0.7.5 skipped the setup screen whenever a save existed ("a
|
||||||
|
saved game is a game to resume"), so any browser that had ever played solitaire could never
|
||||||
|
reach it again. The private window that seemed to vindicate v0.7.7 simply had no save.
|
||||||
|
|
||||||
**And a THIRD report, 2026-08-30, with the build confirmed current on screen — which is what
|
**Each of the three was reported as verified, and each verification read what the SERVER served
|
||||||
finally found it. Fixed in v0.7.8.** v0.7.5 skipped the setup screen whenever a save existed
|
rather than exercising the path with the state a returning player actually has.** The thing that
|
||||||
("a saved game is a game to resume"), so any browser that had ever played solitaire could never
|
worked was a failing test written before the fix. Next time a report repeats: reproduce the
|
||||||
reach it again; the private window that seemed to vindicate v0.7.7 simply had no save. The door
|
user's state first, and treat "I verified it" as unearned until something failed the way they
|
||||||
outranks a save now, a bare reload still resumes, and `#ss-resume` keeps the game in progress
|
described.
|
||||||
one button away since Deal clears it.
|
|
||||||
|
|
||||||
**Three attempts, two of them fixing something real that was not the reported fault.** Each was
|
38. ~~**Gitea#13, #5 and #19 — three rules corrections.**~~ — done 2026-08-29 in v0.7.4 (`5e34c73`,
|
||||||
reported as verified, and each verification read what the SERVER served rather than exercising
|
`2280276`, `19a6a47`), wrapper `085b88b` as `0.7.4:0`, installed on `phoenix.local`. Each issue
|
||||||
the path with the state a returning player actually has. The thing that worked was a failing
|
carries a comment naming its commit and what was ruled. What they left behind is #39, #40 and #41
|
||||||
test written before the fix. Worth remembering next time a report repeats: reproduce the user's
|
above.
|
||||||
state first, and treat "I verified it" as unearned until something failed the way they described.
|
|
||||||
|
|
||||||
Still not verified past that: nobody has clicked all the way through the setup screen's own
|
31. ~~**Bump the StartOS wrapper to 0.7.2.**~~ — done 2026-08-26 (`1bfea8d`). Folded into the
|
||||||
fields and confirmed the dealt game matches what was chosen. Worth being an early item in the
|
standing practice at #37 above, which is where the sequence now lives.
|
||||||
next play session, alongside #39's four unplayed v0.7.4 features.
|
|
||||||
|
25-27. ~~**Three Division-map drawing reports from the v0.7.0 build**~~ — no track geometry, the
|
||||||
|
buffer stop pointing the wrong way at two players, and east not always being to the right. All
|
||||||
|
answered by **Gitea#18**; the last was FIXED OUTRIGHT by it and is the reason a single row won.
|
||||||
|
Collapsed into one entry in Display below.
|
||||||
|
|
||||||
|
19-22, 24. ~~**Five drawing items against the wrapped Division map**~~ — vertical track art, the
|
||||||
|
inter-row connector, overflowing captions, filling the dead centre, and seating the viewer at the
|
||||||
|
bottom. All **SUPERSEDED by Gitea#18**, which replaced the layout rather than fixing the drawing.
|
||||||
|
Two ideas survive them and are recorded in Display below: "SHARED in the middle, YOURS on the
|
||||||
|
right" (now Gitea#20's territory), and why rotating the map to seat a viewer was refused.
|
||||||
|
|
||||||
|
9-11, 12a-12g. ~~**The v0.4.9d and v0.4.9e gameplay-testing reports**~~ — twelve bugs, all shipped in
|
||||||
|
v0.4.9e / v0.7.1. Two trains in one station answering to one button; a Freight House unloading the
|
||||||
|
boxcar it just loaded; the Grocer's Warehouse shipping and the Refinery receiving; Gitea#4 extra
|
||||||
|
train starts; #7 coach counts on four train cards; #6 then #9 on discarding train cards (#9
|
||||||
|
superseded #6 three days later — a Timetabled train may be tossed face-up to a Department slot,
|
||||||
|
an Extra may not); #8 the per-diem train and the caboose; and #10 the Day-rollover dialog.
|
||||||
|
Reasoning for each is in `docs/rules/implications.md` and `CHANGELOG.md`.
|
||||||
|
|
||||||
|
**Gitea#2 carries a RULING worth keeping.** Four porters, two passengers on the platform, and
|
||||||
|
only one may be worked — RULED AND FIXED, but not the way the report implied. The engine is
|
||||||
|
faithful to the written rules at every step; what bites is that BOTH directions of porter work
|
||||||
|
move coaches one-way into a Classification Yard that comes back only when the Division Yard is
|
||||||
|
bare of all ~60 cars. Sixteen coaches in the game, and the reported save runs dry on Day 5 with
|
||||||
|
eight stranded. **Jesse's ruling: the shortage stays** — "it is possible to run out, that's part
|
||||||
|
of the strategy" — so the three balance options in Play Balance below are DECLINED, not deferred.
|
||||||
|
What was actually wrong is that the game said NOTHING: a Porter action that cannot be taken is
|
||||||
|
simply absent from the menu, and the "why is nothing moving?" panel covered freight facilities
|
||||||
|
only. That half is fixed.
|
||||||
|
|
||||||
|
1-6. ~~**The 2026-08-20 multiplayer planning queue and the first StartOS play session**~~ — the New
|
||||||
|
Train phase car-placement round, unified victory conditions, Phase 2 server core, the lobby
|
||||||
|
offering every game parameter, deciding what the four `optionalRules` are (`sisterTrains`
|
||||||
|
deleted, `employeeRotation` implemented, the other two already live), and stopping every release
|
||||||
|
from destroying every game in progress — done by replaying the save rather than comparing version
|
||||||
|
strings. All shipped in v0.6.0; reasoning in Multiplayer below.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -611,7 +563,19 @@ The replay viewer, the save format, and how a game gets shared.
|
|||||||
|
|
||||||
## Play Balance
|
## Play Balance
|
||||||
|
|
||||||
- [ ] **FREIGHT GOT SCARCER WHEN THE MAINLINE WENT ONTO REGIONS, and nobody knows why yet.**
|
- [ ] **A SOLITAIRE GAME CAN NOW END ON THE COLLISION FLOOR (v0.7.9, 2026-08-30) — small, but every
|
||||||
|
full-length figure in this file predates it.** §3.4's check was gated on
|
||||||
|
`mode === 'competitive' || mode === 'coop'`, so the two collision limits were live settings in
|
||||||
|
the solitaire dialog that did nothing; Jesse's ruling was that they should do what they say, and
|
||||||
|
the gate is gone. Measured over 200 standard developer-bot games: `loss/collisionFloor` fires in
|
||||||
|
**1 game in 200**, Days played 5.00 → 4.98 mean with a **minimum of 1**, and collisions per game
|
||||||
|
unchanged at 0.14 (max 3).
|
||||||
|
|
||||||
|
Nothing here needs acting on — the effect is smaller than the noise on every number in this
|
||||||
|
section — but it is a new way for a run to be short, so **a mean taken over games that all ran
|
||||||
|
five Days is no longer quite what is being sampled.** Worth remembering when the rebalance pass
|
||||||
|
re-measures, and worth watching if a future change makes collisions more common, because the
|
||||||
|
cost of one stops being "−1 Revenue" and starts being "the game".
|
||||||
Gitea#3, measured 2026-08-26 across the same 100 games: freight share of gross fell **8% → 5%**,
|
Gitea#3, measured 2026-08-26 across the same 100 games: freight share of gross fell **8% → 5%**,
|
||||||
and completed freight loads went from something a 40-game sample caught reliably to needing
|
and completed freight loads went from something a 40-game sample caught reliably to needing
|
||||||
200 — on `sim.test.ts`'s seeds, 40 games now yield 0 loads, 80 yield 3, 120 yield 10, 200
|
200 — on `sim.test.ts`'s seeds, 40 games now yield 0 loads, 80 yield 3, 120 yield 10, 200
|
||||||
@@ -1411,11 +1375,19 @@ Deferred while planning the server; decisions and reasoning are in `docs/archite
|
|||||||
|
|
||||||
## Display
|
## Display
|
||||||
|
|
||||||
- [ ] **THE BOARD STILL DOES NOT SAY WHICH WAY A HEAVY GRADE CLIMBS.** RAR, twice: "grade should
|
- [x] **~~THE BOARD STILL DOES NOT SAY WHICH WAY A HEAVY GRADE CLIMBS.~~** — done 2026-08-30 in
|
||||||
|
v0.7.9 (`cf018b4`). A brown wedge in the card's lower right rising toward the climb, with an
|
||||||
|
arrow lying along its slope. **It is Gitea#18 that made this drawable**: east is always to the
|
||||||
|
right on a single-row map, so a wedge can be read without a compass — under the wrapped layout
|
||||||
|
the same wedge would have pointed a different way at every seat. Orientation was measured
|
||||||
|
rather than assumed (400 seeds x 4 player counts, 535 grades, 0 without one, 276 east / 259
|
||||||
|
west). Original note below.
|
||||||
|
|
||||||
|
**The board did not say which way a Heavy Grade climbs.** RAR, twice: "grade should
|
||||||
tell you which way is up." `gradeUp` is dealt at setup and drives which of Helpers or
|
tell you which way is up." `gradeUp` is dealt at setup and drives which of Helpers or
|
||||||
Brakeman/Airbrakes can ever pay, and Gitea#3 made it matter more — the modifiers now move a
|
Brakeman/Airbrakes can ever pay, and Gitea#3 made it matter more — the modifiers now move a
|
||||||
train's STARTING REGION, so playing the wrong one is three Stages of climb instead of two. The
|
train's STARTING REGION, so playing the wrong one is three Stages of climb instead of two. The
|
||||||
tooltip says it (`mainlineDescription`); the map does not. Untouched by Gitea#3, which was
|
tooltip says it (`mainlineDescription`); the map did not. Untouched by Gitea#3, which was
|
||||||
about the rules rather than the drawing.
|
about the rules rather than the drawing.
|
||||||
|
|
||||||
|
|
||||||
@@ -1548,202 +1520,59 @@ What is on the screen and where. Split out of Other 2026-08-22; the rules are el
|
|||||||
**The open questions.** Which of the six stay on the top line — Revenue and the objective are
|
**The open questions.** Which of the six stay on the top line — Revenue and the objective are
|
||||||
glanced at constantly and clearly belong there, the seed and the code almost never are. Whether
|
glanced at constantly and clearly belong there, the seed and the code almost never are. Whether
|
||||||
the card folds like `#district` does or is always open. Whether the collision counts belong in
|
the card folds like `#district` does or is always open. Whether the collision counts belong in
|
||||||
it or beside the objective, since they are a live score rather than a setting. And it interacts
|
it or beside the objective, since they are a live score rather than a setting. And it inherits
|
||||||
with the Display items about the right column and the middle of the Division map (item 22): if
|
the one principle that outlived the wrapped-map items (settled below): **SHARED in the middle,
|
||||||
the shared board moves to the centre, the right column is "yours", and a settings card is not
|
YOURS on the right.** A settings card is not yours — it is the table's — so if the common board
|
||||||
yours — it is the table's.
|
of Gitea#20 ever comes back onto this page, the settings card belongs beside it rather than in
|
||||||
|
the right-hand column.
|
||||||
|
|
||||||
- [ ] **INVESTIGATE: the Fedora belongs at the right-hand end of the phase row.** Raised by Jesse
|
- [x] **~~INVESTIGATE: the Fedora belongs at the right-hand end of the phase row.~~** — done
|
||||||
2026-08-23, the day after it was added: "on the next row down, we recently added Superintendent
|
2026-08-30 in v0.7.9 (`Next` #29). Jesse's fallback was the one taken: `.tc-super` moved to the
|
||||||
and saying who's got the Fedora. That information should be all the way on the right, where
|
end of the row, after the pills, and wrapping under them rather than squeezing the chips on a
|
||||||
currently it says 'Supervisor Shift'. Would it be possible to say 'Supervisor Shift — and then
|
narrow screen.
|
||||||
the player name, who's the current supervisor'? If not, just moving the Superintendent and the
|
|
||||||
Fedora graphic to the right-hand side of Supervisor Shift is probably a better place for that."
|
|
||||||
|
|
||||||
**Where it is now.** `turnChartHtml` (`sim/turnchart.ts`, shared by the play screen and both
|
**The appealing version was tried and rejected.** Putting the name INSIDE the Supervisor Shift
|
||||||
replay viewers) lays out four blocks in a row: the Day/Stage/clock, the phase, "waiting on
|
pill reads as "this phase belongs to that player", which is not what the Fedora means — the
|
||||||
<name>", then the Fedora chip (`.tc-super`), and last the `<ol class="tc-phases">` of five phase
|
office holds the clearance ruling and starts every round, in every phase. Worth knowing if
|
||||||
pills. So the Fedora sits in the middle of the row, immediately before the pills.
|
anyone proposes it again.
|
||||||
|
|
||||||
**Putting the name IN the Supervisor Shift pill is the appealing version and needs thought.**
|
**The row was looked at as a whole**, per the note left here at the time, because item #15's
|
||||||
The pills are a WHERE-ARE-WE indicator — each lights violet while its phase is running and dims
|
"most recent action" line still wants space in the same strip. Nothing was taken; the row has
|
||||||
once it is done — so a name inside one may read as "this phase belongs to that player", which is
|
room for one more block at the widths tested.
|
||||||
not what the Fedora means (the office holds the clearance ruling and starts every round, in
|
|
||||||
every phase). The pill is also the one whose tooltip already explains the hat passing every
|
|
||||||
third Stage, which is why it is the natural home. Worth trying both and looking at them.
|
|
||||||
|
|
||||||
**The fallback Jesse names** — move `.tc-super` to the end of the row, after the pills — is a
|
|
||||||
two-line change and safe. Neither should be done without looking at the row as a whole: it is
|
|
||||||
the most-glanced-at strip on the page, and item 15's "most recent action" line wants space in
|
|
||||||
the same place.
|
|
||||||
|
|
||||||
- [ ] **THE DIVISION MAP DRAWS NO TRACK GEOMETRY — a turnout is indistinguishable from a straight.**
|
- [x] **~~Five drawing items against the wrapped Division map~~ — ALL ANSWERED BY Gitea#18**
|
||||||
Reported by Jesse 2026-08-23, playing the v0.7.0 build on StartOS: he upgraded a straight on his
|
(closed 2026-08-26), which replaced the layout rather than fixing the drawing. They were:
|
||||||
Running Track to a turnout and "did not see the division map on my side updated. And in the
|
the map drawing no track geometry; vertical track art on a card laid down a side lane; the
|
||||||
opponent's screen, they did not see any update in their division map either."
|
inter-row connector routed round the outside with angled corners; the Division Point captions
|
||||||
|
overflowing and the buffer stops pointing the wrong way once the route wrapped; and seating the
|
||||||
|
viewer at the bottom with the table wrapped around them. All five were the same pass, and
|
||||||
|
Jesse's instruction was to do them together — which is what made replacing the layout the
|
||||||
|
cheaper answer than drawing it five ways.
|
||||||
|
|
||||||
**It did update. The whole visible change is one word.** Measured rather than reasoned about —
|
**What the single row settled.** The route is one line, west on the left and east on the right,
|
||||||
a real two-player game played out ~260 turns, then a running-track straight upgraded to a
|
at every seat count (`board-svg.ts`). Both buffer stops face outward, so the two "wrong way"
|
||||||
turnout, diffing the drawn SVG on both seats:
|
reports cannot recur. The captions sit under their own Division Point rather than hanging off
|
||||||
|
the ends, so nothing clips. And **east is always to the right** — the report that decided it,
|
||||||
|
and the reason the horseshoe lost to a row that is 1,580px wide at four players against 842.
|
||||||
|
It is also what made the Heavy Grade wedge drawable at all (item above).
|
||||||
|
|
||||||
```
|
**The Division map no longer draws office-area detail**, so there is no Running Track on it to
|
||||||
own division view : CHANGED
|
draw turnout geometry for. That belongs to the Office map, which reads `cell.links` and already
|
||||||
other's view : CHANGED
|
draws it properly.
|
||||||
drawn SVG : CHANGED
|
|
||||||
removed: <text class="bs-name" ...>straight</text>
|
|
||||||
added : <text class="bs-name" ...>turnout</text>
|
|
||||||
```
|
|
||||||
|
|
||||||
**Why.** `divisionSvg` draws the SAME generic rail on every Running Track cell —
|
**Two things survive their items and are NOT closed with them:**
|
||||||
`rail(c.x + 6, c.y + 32, c.x + c.w - 6)`, unconditionally — and prints the card's name under it.
|
|
||||||
So a straight, a curve and a turnout are pixel-identical on the map and only the caption
|
|
||||||
differs. `officeSvg` does it properly for the district grid: it reads `cell.links` and draws the
|
|
||||||
real geometry, 45° legs included.
|
|
||||||
|
|
||||||
**The data is already there and unused.** `RunningCardView` carries
|
- **"SHARED in the middle, YOURS on the right"** — Jesse's principle from the dead-centre item,
|
||||||
`links: connectionsFor(card)` — the same field `officeSvg` draws from — and `divisionSvg` never
|
and worth keeping as the rule that decides where anything new on the screen belongs, even
|
||||||
reads it. So this is a rendering change with no plumbing behind it.
|
though a row has no centre to fill. It is now **Gitea#20**'s territory: the common board is
|
||||||
|
the shared thing, and it got its own display rather than a hole in the map.
|
||||||
|
- **Why seating the viewer at the bottom was refused**, since it will be proposed again: the
|
||||||
|
Division is a LINE, and the open gap between the two buffer stops is the only thing that says
|
||||||
|
so. Rotate the map to seat a viewer and that gap moves with them — sometimes behind them,
|
||||||
|
out of the eye's path, which is exactly where the one feature saying "this is not a circle"
|
||||||
|
must not go. Jesse called it definitively superseded, 2026-08-26.
|
||||||
|
|
||||||
**What is NOT wrong, checked at the same time:** a card laid anywhere BELOW the Running Track
|
|
||||||
correctly changes nothing on the map — the map is the through route between the Limits, and
|
|
||||||
district interiors live in the Office Area grid. Of 4,000 bot steps in a two-player game, all
|
|
||||||
15 running-row plays changed both players' maps and none of the 19 below-the-row plays did. The
|
|
||||||
delta is not dropping anything: `deltaFrame` JSON-compares `division` and sends it whenever it
|
|
||||||
differs.
|
|
||||||
|
|
||||||
**Do it with items 19, 20, 21 and 24** — Jesse's instruction, 2026-08-23. Drawing real geometry
|
|
||||||
is the same pass as turning that geometry through 90° down a side lane, and both are wasted
|
|
||||||
work if the map is later rotated to seat the viewer at the bottom.
|
|
||||||
|
|
||||||
- [ ] **INVESTIGATE: turn the track art vertical on a Division card laid vertically.** Raised by Jesse
|
|
||||||
2026-08-22: a side lane "looks like a bunch of disconnected left-right tracks stacked one on top
|
|
||||||
of another instead of looking like a continuous track."
|
|
||||||
|
|
||||||
**It does, and the cause is one line.** `divisionSvg` draws every cell's rail with
|
|
||||||
`rail(c.x + 6, c.y + 32, c.x + c.w - 6)` — horizontal, unconditionally, whatever side of the
|
|
||||||
table the cell was laid on. **`railV` already exists**, is already used for the connector
|
|
||||||
between stacked cells, and takes the same shape turned ninety degrees. So the cell needs to know
|
|
||||||
which lane it is in (`dir[i]` is `'top' | 'right' | 'bottom' | 'left'`, known at layout time and
|
|
||||||
currently not stored on the cell) and pick the one that matches.
|
|
||||||
|
|
||||||
Everything else inside a side cell — the name, the capacity line, the train chips — is laid out
|
|
||||||
horizontally too, so this is bigger than swapping one call: it is deciding whether a side cell is
|
|
||||||
a rotated card or a differently-arranged one. Worth a sketch before any code.
|
|
||||||
|
|
||||||
- [ ] **INVESTIGATE: run the inter-row connector round the outside, as rail, with angled corners.**
|
|
||||||
Raised by Jesse 2026-08-22. **CONFIRMED IN PLAY 2026-08-23**, and it is worse than a drawing
|
|
||||||
complaint: "as a train traverses the board in a multiplayer game, east is not always to the
|
|
||||||
right. Sometimes, for train direction, east might be south, west, or north as it traverses the
|
|
||||||
different players." The route wrapping through the lanes is exactly this, and a player reading
|
|
||||||
direction off the screen is being told the wrong thing — not merely an ugly corner.
|
|
||||||
|
|
||||||
**Where it comes out is wrong.** The turn between two sides of the table is drawn from
|
|
||||||
`a.y + CH` — the BOTTOM edge of the last cell in the top row — across to `b.y`, the TOP edge of
|
|
||||||
the first cell in the next. So the route leaves the top row's rightmost Limits downward out of
|
|
||||||
its underside, when it should leave from its right-hand side; and on the bottom row it enters
|
|
||||||
through the top edge when it should come in from the right. Jesse: "it should be on the outside
|
|
||||||
circumference of the display."
|
|
||||||
|
|
||||||
**What it is drawn WITH is wrong too.** That connector is a single `<path class="bs-turn">`
|
|
||||||
— one plain polyline — while every other join on the map is `rail()` or `railV()`, two rails
|
|
||||||
with cross ties. The connector between two stacked cells down a side already uses `railV`, which
|
|
||||||
is why the vertical run looks like track and the corners do not.
|
|
||||||
|
|
||||||
**And the corners want an angled piece**, Jesse's wish-list item: a 45° rail between the
|
|
||||||
horizontal and vertical runs rather than a right-angled elbow, matching how the district's own
|
|
||||||
track geometry works (everything leaving a card's north or south edge does so at 45°). That
|
|
||||||
would want a `railD` beside `rail` and `railV`.
|
|
||||||
|
|
||||||
All three are the same drawing pass and should be done together. Note the current elbow routes
|
|
||||||
through `(ay + by) / 2` — the middle of the board — which is the space the item below wants to
|
|
||||||
fill, so these two interact.
|
|
||||||
|
|
||||||
- [ ] **INVESTIGATE: the Division Point captions overflow, and the buffer stops point the wrong way
|
|
||||||
once the route wraps.** Raised by Jesse 2026-08-22. **CONFIRMED IN PLAY 2026-08-23** at two
|
|
||||||
players: "the eastern division point has the end marker to the right, so it overlays where the
|
|
||||||
track is into the person's area, instead of off the left at the actual end of the track." The
|
|
||||||
stop is drawn past the cell's right-hand edge whatever lane the cell ended up in, so at a
|
|
||||||
wrapped route it points back into the board — over a district, not away from the line.
|
|
||||||
|
|
||||||
**The captions.** "west end · in and out" and "east end · in and out" are centred under their own
|
|
||||||
cell at `x = c.x + c.w / 2`. That was a deliberate fix — they used to hang off the outside of
|
|
||||||
the board and print clipped mid-word — and it works for a single row. It does not survive a
|
|
||||||
wrap: a Running Track cell is 78px wide and the caption is not, and `boardW`/`boardH` are
|
|
||||||
computed from CELL extents plus `PAD = 22`, never from the text. **The viewBox does not know the
|
|
||||||
captions exist**, so any caption wider than its cell plus the padding is outside it. This is the
|
|
||||||
same class of bug as the one the comment above the code says was already fixed once; the fix
|
|
||||||
solved the one-row case.
|
|
||||||
|
|
||||||
**The buffer stops.** Both are drawn on the HORIZONTAL ends of their cell — `first.x - 9` and
|
|
||||||
`last.x + last.w + 9` — regardless of which side of the table the cell was laid on. With one row
|
|
||||||
that is right: the line ends to the left and to the right. Wrapped, the East Division Point can
|
|
||||||
end up on the bottom row or down a side, and its stop still points right, into the board rather
|
|
||||||
than away from the route. Jesse's read is that it belongs at the top there. The correct rule is
|
|
||||||
probably "away from the neighbour it joins", which is derivable rather than a special case — and
|
|
||||||
it depends on the vertical-card question above, so do that one first.
|
|
||||||
|
|
||||||
- [ ] **INVESTIGATE: fill the dead centre of the Division map with the common board.** Raised by
|
|
||||||
Jesse 2026-08-22: with three or four seats the map is a ring with a large empty middle, while
|
|
||||||
the timetable sits in a column on the far right.
|
|
||||||
|
|
||||||
**The proposal, in his words:** move the timetable into the middle; maybe the yards, "because
|
|
||||||
that is too applicable to everybody"; maybe the Department and Salvage decks as well, since
|
|
||||||
those apply to all players. "Then the Division map becomes the common board area. The
|
|
||||||
right-hand side — your move, your cards, your facilities, what's blocked — is your side of
|
|
||||||
things."
|
|
||||||
|
|
||||||
**That is a genuinely good division of the screen** and it names a principle the layout does not
|
|
||||||
currently have: SHARED in the middle, YOURS on the right. Worth writing down as the rule even if
|
|
||||||
the move itself is deferred, because it decides where anything new belongs.
|
|
||||||
|
|
||||||
**What makes it awkward.** The middle is only empty at three and four seats — at one seat there
|
|
||||||
is no middle at all, and at two the rows face each other across a gap the width of `SIDE_GAP`.
|
|
||||||
So whatever goes there needs somewhere else to live at low seat counts, which is close to
|
|
||||||
building both layouts. And the map is an SVG built by `divisionSvg` while the timetable, yards
|
|
||||||
and decks are HTML panels (`panels.ts`), so "put them in the middle" means either
|
|
||||||
foreign-objecting HTML into the SVG or positioning HTML over it — neither free, and the map is
|
|
||||||
embedded into the replay by `toString()`, which constrains what it may reach for.
|
|
||||||
|
|
||||||
- [ ] **INVESTIGATE: seat the viewer at the bottom of the Division map and wrap the table around
|
|
||||||
them.** Raised by Jesse 2026-08-22: "consider that the player being displayed is always at the
|
|
||||||
bottom of the display, and the rest of the table is wrapped around. This would give something
|
|
||||||
of a feel of sitting at an actual table with my cards in front of me and the other players in
|
|
||||||
front of me as well." His own note: not a decision, something to think about.
|
|
||||||
|
|
||||||
**It needs no new data.** `divisionSvg` already takes `roster.viewer` — "the player this map is
|
|
||||||
being drawn for" — and `main.ts` already passes it. The layout is a fixed lane order,
|
|
||||||
`top → right → bottom → left`, filled in ROUTE order west to east; rotating means offsetting
|
|
||||||
which lane the first seat lands in so the viewer's own district comes out on `bottom`. That is
|
|
||||||
an index shift in one array, not a new layout engine.
|
|
||||||
|
|
||||||
**The strongest argument for it is already in the code.** The map marks your district with a
|
|
||||||
colour AND spells out "(you)", and the comment says why: "a colour alone cannot say which of
|
|
||||||
four railroads is the reader's, and that is the first thing anybody wants to know at a table
|
|
||||||
they just sat down at." A fixed position answers that structurally — you would know before
|
|
||||||
reading anything. The marker stays as reinforcement rather than being the only signal.
|
|
||||||
|
|
||||||
**What it costs is the other thing the map says.** The section is headed "The Division — west
|
|
||||||
to east" and the route is a LINE, not a loop: it starts at the West Division Point and ends at
|
|
||||||
the East one, with buffer stops at both and a deliberately open gap between them. Today that
|
|
||||||
line starts top-left, where a reader starts reading. Rotate it and west starts wherever your
|
|
||||||
seat put it — and the open gap, which is the thing that stops the ring being read as a loop,
|
|
||||||
moves with it. Sometimes it would land behind you, out of the eye's path, which is exactly
|
|
||||||
where the one feature that says "this is not a circle" should not be.
|
|
||||||
|
|
||||||
**So the question is which of the two the map is FOR**, and it may not have the same answer at
|
|
||||||
every seat count. At one seat there is nothing to rotate. At two it is a swap, and free. At
|
|
||||||
three, rotating changes which way the horseshoe opens. At four it moves the break in the
|
|
||||||
square. A rule like "rotate at three and four, leave one and two alone" is defensible but has
|
|
||||||
to be decided rather than fallen into.
|
|
||||||
|
|
||||||
**One caller has no viewer at all**: the site's replay viewer calls `divisionSvg(f.division)`
|
|
||||||
with no roster (`web/replays.ts`). A replay watches every seat and belongs to none, so it has
|
|
||||||
nothing to rotate around — the feature has to degrade cleanly to today's layout there, which is
|
|
||||||
an argument for building it as an optional rotation rather than as the layout.
|
|
||||||
|
|
||||||
**Same drawing pass as items 19, 20 and 22** — vertical track art, the corner connectors and
|
|
||||||
the dead centre. All four move cells around the ring or change what is drawn inside them, and
|
|
||||||
doing them one at a time means laying the map out four times.
|
|
||||||
|
|
||||||
- [ ] **INVESTIGATE: history newest-at-the-top, and timestamps on it.** Raised by Jesse 2026-08-22.
|
- [ ] **INVESTIGATE: history newest-at-the-top, and timestamps on it.** Raised by Jesse 2026-08-22.
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "station-master",
|
"name": "station-master",
|
||||||
"version": "0.7.8",
|
"version": "0.7.9",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Station Master — a railroad operations game",
|
"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 });
|
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
|
* §3.4 — EVERY MODE, SOLITAIRE INCLUDED: a Day's collisions against `maxCollisionsPerDay` and the
|
||||||
// scaled by player count — Jesse's call, 2026-08-20: more players is more independent chances to
|
* game's running total against `maxCollisionsTotal`. `0` disables either check. Flat, not scaled
|
||||||
// collide, not a bigger shared budget.
|
* by player count — Jesse's call, 2026-08-20: more players is more independent chances to collide,
|
||||||
if (s.config.mode === 'competitive' || s.config.mode === 'coop') {
|
* 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 =
|
const perDayBreach =
|
||||||
s.config.maxCollisionsPerDay > 0 && s.collisionsToday >= s.config.maxCollisionsPerDay;
|
s.config.maxCollisionsPerDay > 0 && s.collisionsToday >= s.config.maxCollisionsPerDay;
|
||||||
const totalBreach =
|
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
|
* 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,
|
* ONE ROW AT EVERY SEAT COUNT (Gitea#18). It used to be laid out the way players sit — two rows
|
||||||
* two rows facing, a horseshoe of three, a square of four. The Division is a LINE and not a loop
|
* facing, a horseshoe of three, a square of four — and the reasoning for dropping that is at the
|
||||||
* — trains enter at one Division Point and leave at the other — so the shape is deliberately left
|
* layout itself below. The Division is a LINE and not a loop — trains enter at one Division Point
|
||||||
* open, with the two ends drawn as buffer stops facing each other across a marked gap. Closing it
|
* and leave at the other — so the row is deliberately left open, with the two ends drawn as
|
||||||
* into a ring would promise a connection the rules do not have.
|
* 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
|
* Self-contained on purpose: the replay embeds this by `toString()`, so it may not reach for
|
||||||
* anything outside its own body.
|
* 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.
|
* this is back to what a buffer stop actually needs.
|
||||||
*/
|
*/
|
||||||
const PAD = 22;
|
const PAD = 22;
|
||||||
const SIDE_GAP = 34;
|
|
||||||
|
|
||||||
const esc = (t: string): string =>
|
const esc = (t: string): string =>
|
||||||
String(t).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c] ?? c);
|
String(t).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c] ?? c);
|
||||||
@@ -146,6 +146,12 @@ export function divisionSvg(nodes: DivisionView[], roster?: DivisionRoster | nul
|
|||||||
below?: Cell['trains'];
|
below?: Cell['trains'];
|
||||||
/** Mainline cards only: §2.1 divides one into two regions. 0 elsewhere — no bars are drawn. */
|
/** Mainline cards only: §2.1 divides one into two regions. 0 elsewhere — no bars are drawn. */
|
||||||
regions: number;
|
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;
|
w: number;
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
@@ -259,6 +265,7 @@ export function divisionSvg(nodes: DivisionView[], roster?: DivisionRoster | nul
|
|||||||
seat: null,
|
seat: null,
|
||||||
// A Division Point is one region — the queue trains enter and leave the Division through.
|
// A Division Point is one region — the queue trains enter and leave the Division through.
|
||||||
regions: dp ? 1 : (n.regions ?? 0),
|
regions: dp ? 1 : (n.regions ?? 0),
|
||||||
|
gradeUp: dp ? null : (n.gradeUp ?? null),
|
||||||
w: dp ? CW.dp : CW.ml,
|
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}"/>`;
|
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.
|
* 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
|
/* 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. */
|
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}
|
.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{fill:none;stroke:#5f6b7a;stroke-width:1.1;stroke-dasharray:3 2}
|
||||||
.bs-slot.bs-occ{stroke-dasharray:none;stroke-width:1.6}
|
.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
|
/* 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 { createGame } from '../engine/setup.ts';
|
||||||
import type { Facility, GameConfig, GameState } from '../engine/state.ts';
|
import type { Facility, GameConfig, GameState } from '../engine/state.ts';
|
||||||
import { actingPlayer } from '../engine/state.ts';
|
import { actingPlayer } from '../engine/state.ts';
|
||||||
|
import { reasonSentence } from '../web/panels.ts';
|
||||||
import { developerBot, lastChoiceReason } from './bot.ts';
|
import { developerBot, lastChoiceReason } from './bot.ts';
|
||||||
import { carLabel, cuesFor, idleNote, isVisible, narrate } from './narrate.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
|
// 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);
|
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 o = s.outcome;
|
||||||
|
const last = frames[frames.length - 1];
|
||||||
|
const why = o && last ? reasonSentence(last, o, last.day).replace(/<[^>]+>/g, '') : '';
|
||||||
return {
|
return {
|
||||||
seed,
|
seed,
|
||||||
length,
|
length,
|
||||||
frames,
|
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;
|
phase: string;
|
||||||
phaseKey: string;
|
phaseKey: string;
|
||||||
actor: number | null;
|
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('');
|
}).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 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 =
|
const fedora =
|
||||||
superName === null
|
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>` +
|
`<div class="tc-when"><b>Day ${f.day}</b><span>Stage ${f.stage} of 12</span>` +
|
||||||
`<span class="dim">${esc(f.clock)}</span></div>` +
|
`<span class="dim">${esc(f.clock)}</span></div>` +
|
||||||
`<div class="tc-now">phase <b>${esc(f.phase)}</b></div>` +
|
`<div class="tc-now">phase <b>${esc(f.phase)}</b></div>` +
|
||||||
`<div class="tc-who">waiting on <b>${esc(who)}</b></div>` +
|
`<div class="tc-who">waiting on <b>${esc(who)}</b>${asked}</div>` +
|
||||||
fedora +
|
// THE FEDORA RIDES AT THE END OF THE PHASE ROW (`TODO.md` #29, Jesse). It sat on its own line
|
||||||
`<ol class="tc-phases">${chips}</ol>`
|
// 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
|
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. */
|
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-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;
|
.tc-who b{color:#b98cf0;background:rgba(150,110,230,.16);border:1px solid #8b6ad0;
|
||||||
border-radius:11px;padding:1px 9px;font-size:12px}
|
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
|
/* 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
|
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. */
|
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}
|
.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}
|
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;
|
.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';
|
} from '../engine/content.ts';
|
||||||
import type { Intent } from '../engine/intents.ts';
|
import type { Intent } from '../engine/intents.ts';
|
||||||
import type { Facility, GameConfig, GameState, PlayerIndex, SeatIndex, TrackCard, TurnoutOrientation } from '../engine/state.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 { Hand, HouseRules, TrackGeometry } from '../engine/content.ts';
|
||||||
import type { Port } from '../engine/track.ts';
|
import type { Port } from '../engine/track.ts';
|
||||||
import { connectionsFor, slopeOfPair, variantsFor } 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.
|
* replay recorder, which sees the events; the live game keeps its own on the Game object.
|
||||||
*/
|
*/
|
||||||
cues?: string[];
|
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;
|
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;
|
superintendent: number;
|
||||||
revenue: number;
|
revenue: number;
|
||||||
/**
|
/**
|
||||||
@@ -1342,7 +1356,19 @@ export function snapshot(
|
|||||||
clock: clockTime(s.clock.stage),
|
clock: clockTime(s.clock.stage),
|
||||||
phase: phaseLabel(s.clock.phase),
|
phase: phaseLabel(s.clock.phase),
|
||||||
phaseKey: 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,
|
superintendent: s.clock.superintendent,
|
||||||
revenue: s.players[viewer]?.revenue ?? 0,
|
revenue: s.players[viewer]?.revenue ?? 0,
|
||||||
lines,
|
lines,
|
||||||
|
|||||||
+17
-2
@@ -134,10 +134,25 @@ export type NewGameOptions = {
|
|||||||
|
|
||||||
/** The same config with the New Game dialog's answers in it. */
|
/** The same config with the New Game dialog's answers in it. */
|
||||||
export function configWith(opts: NewGameOptions): GameConfig {
|
export function configWith(opts: NewGameOptions): GameConfig {
|
||||||
|
const days = opts.days ?? SOLO_CONFIG.days;
|
||||||
return {
|
return {
|
||||||
...SOLO_CONFIG,
|
...SOLO_CONFIG,
|
||||||
days: opts.days ?? SOLO_CONFIG.days,
|
days,
|
||||||
minCombinedRevenue: opts.minCombinedRevenue ?? SOLO_CONFIG.minCombinedRevenue,
|
/**
|
||||||
|
* 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,
|
maxCollisionsPerDay: opts.maxCollisionsPerDay ?? SOLO_CONFIG.maxCollisionsPerDay,
|
||||||
maxCollisionsTotal: opts.maxCollisionsTotal ?? SOLO_CONFIG.maxCollisionsTotal,
|
maxCollisionsTotal: opts.maxCollisionsTotal ?? SOLO_CONFIG.maxCollisionsTotal,
|
||||||
optionalRules: { ...SOLO_CONFIG.optionalRules, ...(opts.optionalRules ?? {}) },
|
optionalRules: { ...SOLO_CONFIG.optionalRules, ...(opts.optionalRules ?? {}) },
|
||||||
|
|||||||
+18
-25
@@ -256,25 +256,21 @@ export function runLobby(handlers: LobbyHandlers, resume?: { token: string; game
|
|||||||
else if (type === 'custom') type = base;
|
else if (type === 'custom') type = base;
|
||||||
for (const r of typeRadios()) r.checked = r.value === type;
|
for (const r of typeRadios()) r.checked = r.value === type;
|
||||||
|
|
||||||
const scoring = preset(base).scoring;
|
/**
|
||||||
const note = $('lb-type-note');
|
* NO SENTENCE UNDER THE RADIOS since 2026-08-30 — it restated the type just chosen to the person
|
||||||
if (type === 'custom') {
|
* who had just chosen it, and the row is already labelled and already carries its own
|
||||||
note.textContent =
|
* description (Jesse: "There's no need to repeat it below"). `form.mark` still puts a hint on
|
||||||
`${gameTypeLabel('custom', scoring)} · ${differing.length} ` +
|
* each row that actually differs, which is where a Custom game's differences can be acted on.
|
||||||
`${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.
|
||||||
// A Custom game is nobody's default: open the block that says how it differs.
|
if (type === 'custom') $<HTMLDetailsElement>('lb-settings').open = true;
|
||||||
$<HTMLDetailsElement>('lb-settings').open = true;
|
|
||||||
} else {
|
|
||||||
note.textContent = preset(type as PresetName).blurb;
|
|
||||||
note.className = 'ng-note';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const r of typeRadios()) {
|
for (const r of typeRadios()) {
|
||||||
// Solitaire is on this screen so the two screens read as one list, but there is nothing here to
|
// 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.
|
// deal it with. Dimmed and left to speak for itself: the heading says "Game type
|
||||||
if (r.value === 'solitaire') markUnavailable(r, 'dealt with the New game button, not here');
|
// (multi-player)", which is the explanation (Jesse, 2026-08-30).
|
||||||
|
if (r.value === 'solitaire') markUnavailable(r);
|
||||||
r.onchange = () => {
|
r.onchange = () => {
|
||||||
if (!r.checked) return;
|
if (!r.checked) return;
|
||||||
if (r.value === 'custom') {
|
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`
|
* 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
|
* 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;
|
radio.disabled = true;
|
||||||
const row = radio.closest('label');
|
radio.closest('label')?.classList.add('disabled');
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function escapeHtml(s: string): string {
|
function escapeHtml(s: string): string {
|
||||||
|
|||||||
+133
-88
@@ -506,7 +506,7 @@ const lobbyHandlers = {
|
|||||||
const record = readStore().games[gameId];
|
const record = readStore().games[gameId];
|
||||||
if (!record) return;
|
if (!record) return;
|
||||||
if (record.stage === 'game' && record.seat !== undefined) {
|
if (record.stage === 'game' && record.seat !== undefined) {
|
||||||
beginRemote({ ...record, seat: record.seat });
|
beginRemote({ ...record, seat: record.seat }, true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Still seated in a lobby that had not started: the stream puts us back on the seating screen,
|
// Still seated in a lobby that had not started: the stream puts us back on the seating screen,
|
||||||
@@ -533,6 +533,8 @@ const HANDOFF_STALL_MS = 8000;
|
|||||||
let handoffOpenedAt = 0;
|
let handoffOpenedAt = 0;
|
||||||
let handoffStall: number | null = null;
|
let handoffStall: number | null = null;
|
||||||
let firstFrameSeen = false;
|
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 {
|
function setText(id: string, text: string): void {
|
||||||
const el = document.getElementById(id);
|
const el = document.getElementById(id);
|
||||||
@@ -612,7 +614,7 @@ function noteDayEnd(f: Frame): void {
|
|||||||
* nothing saying this was the game just set up. `#phasenote` cannot help: it announces a CHANGE of
|
* 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.
|
* 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;
|
if (firstFrameSeen || isLocal(session)) return;
|
||||||
firstFrameSeen = true;
|
firstFrameSeen = true;
|
||||||
const held = Date.now() - handoffOpenedAt;
|
const held = Date.now() - handoffOpenedAt;
|
||||||
@@ -621,14 +623,34 @@ function noteFirstFrame(f: Frame): void {
|
|||||||
window.setTimeout(
|
window.setTimeout(
|
||||||
() => {
|
() => {
|
||||||
closeHandoff();
|
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(
|
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),
|
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),
|
/** 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
|
* `#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,
|
* solitaire deal, the same way `#lobby` is asked before the first multiplayer one — Jesse,
|
||||||
@@ -646,7 +668,7 @@ function showScreen(which: 'lobby' | 'gameui' | 'solitairesetup'): void {
|
|||||||
* earlier visit. Either way the token is what makes reconnection work (`lobby-and-sessions.md` §1),
|
* 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.
|
* 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' });
|
saveRemote({ token: ready.token, gameId: ready.gameId, gameCode: ready.gameCode, seat: ready.seat, stage: 'game' });
|
||||||
gameCode = ready.gameCode;
|
gameCode = ready.gameCode;
|
||||||
showScreen('gameui');
|
showScreen('gameui');
|
||||||
@@ -656,6 +678,7 @@ function beginRemote(ready: LobbyReady): void {
|
|||||||
// banner (`#presence`), and it holds a beat so the game visibly begins.
|
// banner (`#presence`), and it holds a beat so the game visibly begins.
|
||||||
openHandoff();
|
openHandoff();
|
||||||
session = createRemoteSession(ready.token, ready.seat, abandonRemote);
|
session = createRemoteSession(ready.token, ready.seat, abandonRemote);
|
||||||
|
rejoiningRemote = rejoining;
|
||||||
applyCapabilities();
|
applyCapabilities();
|
||||||
// A LocalSession has data the instant it is constructed; a RemoteSession does not — its first
|
// 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
|
// real Frame only exists once the SSE connection's first push arrives, so the first render waits
|
||||||
@@ -742,7 +765,7 @@ function start(): void {
|
|||||||
// session reports a dead game through `abandonRemote`, which lands in the lobby.
|
// session reports a dead game through `abandonRemote`, which lands in the lobby.
|
||||||
const remembered = wantsSolitaire ? null : loadRemote();
|
const remembered = wantsSolitaire ? null : loadRemote();
|
||||||
if (remembered && remembered.stage === 'game' && remembered.seat !== undefined) {
|
if (remembered && remembered.stage === 'game' && remembered.seat !== undefined) {
|
||||||
beginRemote({ ...remembered, seat: remembered.seat });
|
beginRemote({ ...remembered, seat: remembered.seat }, true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
@@ -799,13 +822,17 @@ function start(): void {
|
|||||||
const seed = requested !== null ? Number(requested) || 1 : Math.floor(Math.random() * 1e9);
|
const seed = requested !== null ? Number(requested) || 1 : Math.floor(Math.random() * 1e9);
|
||||||
const local = createLocalSession(seed, solitaireDefaults(gameOptionsFromUrl(params)));
|
const local = createLocalSession(seed, solitaireDefaults(gameOptionsFromUrl(params)));
|
||||||
session = local;
|
session = local;
|
||||||
if (saved && requested === null) local.restore(saved);
|
const restored = Boolean(saved) && requested === null;
|
||||||
|
if (saved && restored) local.restore(saved);
|
||||||
|
|
||||||
applyCapabilities();
|
applyCapabilities();
|
||||||
// Every render goes through the session, so the page redraws whenever the game says it changed —
|
// 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.
|
// which is what a remote session will use to push. Locally it fires on each accepted intent.
|
||||||
session.subscribe(render);
|
session.subscribe(render);
|
||||||
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());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -934,7 +961,7 @@ function rulesSummary(f: Frame): string {
|
|||||||
function render(): void {
|
function render(): void {
|
||||||
const f = session.view();
|
const f = session.view();
|
||||||
const menu = session.menu();
|
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
|
// 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.
|
// coordinate list into a board: you pick the thing, then click where it goes.
|
||||||
@@ -1554,6 +1581,33 @@ function showResults(f: Frame): void {
|
|||||||
const body = document.getElementById('resultsbody');
|
const body = document.getElementById('resultsbody');
|
||||||
if (!dlg || !body) return;
|
if (!dlg || !body) return;
|
||||||
body.innerHTML = resultsHtml(f);
|
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
|
// A redraw can arrive while it is open — `showModal` throws on an already-open dialog rather
|
||||||
// than doing nothing (the same trap `noteDayEnd` documents).
|
// than doing nothing (the same trap `noteDayEnd` documents).
|
||||||
if (!dlg.open) dlg.showModal();
|
if (!dlg.open) dlg.showModal();
|
||||||
@@ -1992,12 +2046,16 @@ function wireGameTypeBlock(prefix: string, root: ParentNode): WiredGameType {
|
|||||||
if (differing.length > 0) type = 'custom';
|
if (differing.length > 0) type = 'custom';
|
||||||
else if (type === 'custom') type = base;
|
else if (type === 'custom') type = base;
|
||||||
for (const r of typeRadios()) r.checked = r.value === type;
|
for (const r of typeRadios()) r.checked = r.value === type;
|
||||||
const note = field<HTMLElement>('type-note');
|
/**
|
||||||
note.textContent =
|
* NO SENTENCE UNDER THE RADIOS. It restated the type just chosen — the row is already labelled
|
||||||
type === 'custom'
|
* and already carries its own one-line description — so it was the choice read back to the
|
||||||
? `${gameTypeLabel('custom', preset(base).scoring)} · ${differing.length} ` +
|
* person who had just made it (Jesse, 2026-08-30: "It's obvious from what they selected above
|
||||||
`${differing.length === 1 ? 'setting differs' : 'settings differ'} from ${preset(base).label}.`
|
* what they're playing. There's no need to repeat it below.").
|
||||||
: preset(type as PresetName).blurb;
|
*
|
||||||
|
* 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 {
|
function selectPreset(name: PresetName): void {
|
||||||
@@ -2016,19 +2074,21 @@ function wireGameTypeBlock(prefix: string, root: ParentNode): WiredGameType {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const r of typeRadios()) {
|
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
|
* Nothing here can deal a multiplayer game: a `LocalSession` runs the engine in this browser and
|
||||||
// the click (Jesse, 2026-08-23 — a disabled radio that looks enabled reads as a broken one).
|
* 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') {
|
if (r.value !== 'solitaire' && r.value !== 'custom') {
|
||||||
r.disabled = true;
|
r.disabled = true;
|
||||||
const row = r.closest('label');
|
r.closest('label')?.classList.add('disabled');
|
||||||
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.onchange = () => {
|
r.onchange = () => {
|
||||||
if (!r.checked) return;
|
if (!r.checked) return;
|
||||||
@@ -2110,65 +2170,26 @@ function commitNewGame(wired: WiredGameType, seedFieldValue: string): void {
|
|||||||
else location.search = next;
|
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 newBtn = document.getElementById('newgame');
|
||||||
const dlg = document.getElementById('newgamedlg') as HTMLDialogElement | null;
|
if (newBtn) {
|
||||||
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.
|
|
||||||
*/
|
|
||||||
newBtn.onclick = () => {
|
newBtn.onclick = () => {
|
||||||
// The button itself is hidden for a session that cannot deal (`applyCapabilities`), but the
|
showScreen('solitairesetup');
|
||||||
// dialog's whole answer-reading/URL-navigating flow below assumes a LocalSession throughout, so
|
// IN PLACE, not a navigation: the live session stays in memory, so the fields can open on the
|
||||||
// the guard is repeated — and `local` is captured as a `const` so the narrowing survives the
|
// rules actually being played and "Continue saved game" is just showing the board
|
||||||
// closures below it (see `renderUndo`'s identical note on why `session` itself cannot be).
|
// again rather than a reload and a replay.
|
||||||
if (!isLocal(session)) return;
|
runSolitaireSetup(new URLSearchParams(), true, session.view());
|
||||||
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();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* 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 +2202,7 @@ if (newBtn && dlg) {
|
|||||||
* Solitaire defaults, since there is no live game to compare against yet, and reuses the identical
|
* 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.
|
* `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 screen = document.getElementById('solitairesetup');
|
||||||
const dealBtn = document.getElementById('ss-deal');
|
const dealBtn = document.getElementById('ss-deal');
|
||||||
if (!screen || !dealBtn) return;
|
if (!screen || !dealBtn) return;
|
||||||
@@ -2193,25 +2214,49 @@ function runSolitaireSetup(params: URLSearchParams, hasSave = false): void {
|
|||||||
const seedField = document.getElementById('ss-seed') as HTMLInputElement | null;
|
const seedField = document.getElementById('ss-seed') as HTMLInputElement | null;
|
||||||
if (seedField) seedField.value = params.get('seed') ?? '';
|
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.
|
* 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
|
* 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
|
* 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.
|
* 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
|
* Mid-game the game is still in memory, so going back is just showing it again. From the splash
|
||||||
* resumes a save on a bare load, and routing both paths through it keeps one place that turns a
|
* there is nothing loaded yet, so it is a navigation to the bare URL and `start()` restores the
|
||||||
* URL into a game.
|
* save — one place that turns a URL into a game, either way.
|
||||||
*/
|
*/
|
||||||
const resumeBtn = document.getElementById('ss-resume');
|
const resumeBtn = document.getElementById('ss-resume');
|
||||||
const savedNote = document.getElementById('ss-saved-note');
|
const savedNote = document.getElementById('ss-saved-note');
|
||||||
|
const canResume = hasSave || live !== null;
|
||||||
if (resumeBtn) {
|
if (resumeBtn) {
|
||||||
resumeBtn.hidden = !hasSave;
|
resumeBtn.hidden = !canResume;
|
||||||
resumeBtn.onclick = () => void (location.search = '');
|
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 ?? '');
|
dealBtn.onclick = () => commitNewGame(ss, seedField?.value ?? '');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+6
-1
@@ -254,8 +254,13 @@ function collisionsHtml(f: Frame): string {
|
|||||||
* game read the words `GAME OVER — revenueFloor`: an internal enum value, printed at the one moment
|
* 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
|
* 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.
|
* 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);
|
const combined = f.players.reduce((n, p) => n + p.revenue, 0);
|
||||||
switch (o.reason) {
|
switch (o.reason) {
|
||||||
case 'daysElapsed':
|
case 'daysElapsed':
|
||||||
|
|||||||
+77
-206
@@ -76,6 +76,19 @@ dialog input:focus{outline:none;border-color:#4d6fa8}
|
|||||||
padding:5px 14px;cursor:pointer;font:inherit;font-size:13px}
|
padding:5px 14px;cursor:pointer;font:inherit;font-size:13px}
|
||||||
.ng-buttons button:hover{border-color:#4d6fa8}
|
.ng-buttons button:hover{border-color:#4d6fa8}
|
||||||
#ng-deal{background:#31527f;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}
|
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}}
|
@media(max-width:1100px){main{grid-template-columns:1fr}}
|
||||||
section{background:var(--panel);border:1px solid var(--line);border-radius:7px;
|
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. */
|
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{opacity:.45;cursor:not-allowed}
|
||||||
.ng-radio.disabled:hover{background:none}
|
.ng-radio.disabled:hover{background:none}
|
||||||
.lb-why{color:#e0b060;font-size:11px}
|
|
||||||
#lobby h2,#solitairesetup h2{margin-top:0}
|
#lobby h2,#solitairesetup h2{margin-top:0}
|
||||||
#lobby h3,#solitairesetup h3{margin-bottom:2px}
|
#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)}
|
.lb-seat{display:flex;align-items:center;gap:8px;padding:5px 0;border-bottom:1px solid var(--line)}
|
||||||
@@ -307,9 +319,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
|
<!-- 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;
|
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.
|
`main.ts` only decides whether THIS div, `#solitairesetup` or `#gameui` is the one visible.
|
||||||
`#newgamedlg` at the very end of the body is solitaire-only, and asks the same questions through
|
`#solitairesetup` asks the same questions of a solitaire player, through the same shared module
|
||||||
the same shared module (`settings-form.ts`) — the two blocks are generated from one template. -->
|
(`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>
|
<div id="lobby" hidden>
|
||||||
<header><b><a href="./index.html" class="home">Station Master</a></b> — <span class="dim">Multiplayer</span></header>
|
<header><b><a href="./index.html" class="home">Station Master</a></b> — <span class="dim">Multiplayer</span></header>
|
||||||
|
|
||||||
@@ -402,12 +415,13 @@ ul.blocked li{padding:2px 0}
|
|||||||
can be shared, compared or replayed. Leave it blank for a random one.</p>
|
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
|
<!-- 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. -->
|
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
|
<p class="ng-note">Every chair must be filled before the game can start. For solitaire,
|
||||||
bot. Pick the size of the table now; it cannot change once the game is created.</p>
|
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>
|
||||||
|
|
||||||
<div class="lb-col">
|
<div class="lb-col">
|
||||||
<h3>Game type</h3>
|
<h3>Game type (multi-player)</h3>
|
||||||
<div class="set-row" id="lb-type-row">
|
<div class="set-row" id="lb-type-row">
|
||||||
<label class="ng-radio"><input type="radio" name="lb-type" value="solitaire">
|
<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>
|
<span><b>Solitaire</b><br><span class="dim">One railroad, one player. The whole Division is yours to run.</span></span></label>
|
||||||
@@ -421,7 +435,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>
|
<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>
|
</div>
|
||||||
|
|
||||||
<p class="ng-note" id="lb-type-note"></p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- FULL WIDTH WHEN IT OPENS. Reported by Jesse: opened inside the right-hand column it made a
|
<!-- FULL WIDTH WHEN IT OPENS. Reported by Jesse: opened inside the right-hand column it made a
|
||||||
@@ -431,7 +445,7 @@ ul.blocked li{padding:2px 0}
|
|||||||
<summary>Game settings</summary>
|
<summary>Game settings</summary>
|
||||||
<p class="ng-note">Every rule the game type sets, and every one of them yours to change.
|
<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
|
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>
|
game is created and cannot be changed once it starts.</p>
|
||||||
<div class="set-groups">
|
<div class="set-groups">
|
||||||
|
|
||||||
@@ -505,13 +519,13 @@ ul.blocked li{padding:2px 0}
|
|||||||
</div>
|
</div>
|
||||||
<div class="set-row" id="lb-colday-row">
|
<div class="set-row" id="lb-colday-row">
|
||||||
<label class="ng-gate"><input type="checkbox" id="lb-colday-on" checked>
|
<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>
|
<input id="lb-colday" type="number" min="0" step="1" class="gate-num"></label>
|
||||||
<span class="set-hint" id="lb-colday-hint"></span>
|
<span class="set-hint" id="lb-colday-hint"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="set-row" id="lb-coltotal-row">
|
<div class="set-row" id="lb-coltotal-row">
|
||||||
<label class="ng-gate"><input type="checkbox" id="lb-coltotal-on" checked>
|
<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>
|
<input id="lb-coltotal" type="number" min="0" step="1" class="gate-num"></label>
|
||||||
<span class="set-hint" id="lb-coltotal-hint"></span>
|
<span class="set-hint" id="lb-coltotal-hint"></span>
|
||||||
</div>
|
</div>
|
||||||
@@ -522,7 +536,7 @@ ul.blocked li{padding:2px 0}
|
|||||||
|
|
||||||
<div class="set-group">
|
<div class="set-group">
|
||||||
<h3>Optional rules</h3>
|
<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">
|
<div class="set-row" id="lb-visibility-row">
|
||||||
<label class="ng-num"><span>Reduced Visibility — five switching Moves instead of six in the
|
<label class="ng-num"><span>Reduced Visibility — five switching Moves instead of six in the
|
||||||
night Stages (1–3 and 11–12)</span>
|
night Stages (1–3 and 11–12)</span>
|
||||||
@@ -555,7 +569,7 @@ ul.blocked li{padding:2px 0}
|
|||||||
</details>
|
</details>
|
||||||
|
|
||||||
<div class="lb-span">
|
<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>
|
<p class="lb-error" id="lb-create-err" role="alert"></p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -616,16 +630,29 @@ ul.blocked li{padding:2px 0}
|
|||||||
<p class="ng-note">One railroad, one player, five full days by default — everything below is
|
<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>
|
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">
|
<div class="lb-params">
|
||||||
<label class="ng-num"><span>Seed</span>
|
<label class="ng-num"><span>Seed</span>
|
||||||
<input id="ss-seed" type="text" inputmode="numeric" autocomplete="off" placeholder="blank for a random seed"></label>
|
<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>
|
<label class="ng-num"><span>Days</span>
|
||||||
<input id="ss-days" type="number" min="1" max="20" step="1" value="5"></label>
|
<input id="ss-days" type="number" min="1" max="20" step="1" value="5"></label>
|
||||||
</div>
|
</div>
|
||||||
<p class="ng-note">The same seed and the same settings always deal the same railroad, so a game
|
<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>
|
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">
|
<div class="set-row" id="ss-type-row">
|
||||||
<label class="ng-radio"><input type="radio" name="ss-type" value="solitaire" checked>
|
<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>
|
<span><b>Solitaire</b><br><span class="dim">One railroad, one player. The whole Division is yours to run.</span></span></label>
|
||||||
@@ -639,19 +666,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>
|
<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>
|
</div>
|
||||||
|
|
||||||
<p class="ng-note" id="ss-type-note"></p>
|
|
||||||
|
|
||||||
<details id="ss-settings" open>
|
<details id="ss-settings" open>
|
||||||
<summary>Game settings</summary>
|
<summary>Game settings</summary>
|
||||||
<p class="ng-note">Every rule the game type sets, and every one of them yours to change.
|
<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
|
Changing any of them selects <b>Custom</b>, which keeps the scoring of the type you
|
||||||
to it.</p>
|
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-groups">
|
||||||
|
|
||||||
<div class="set-group">
|
<div class="set-group">
|
||||||
<h3>Starting hand</h3>
|
<h3>Starting hand</h3>
|
||||||
<p class="ng-note">What you are dealt before the first turn. The hand limit is three either
|
<p class="ng-note">What each player is dealt before the first turn. The hand limit is three
|
||||||
way — deal six and the first turn is spent choosing which of them to keep.</p>
|
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">
|
<div class="set-row" id="ss-hand-row">
|
||||||
<label class="ng-radio"><input type="radio" name="ss-hand" value="threeRandom">
|
<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>
|
<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 +736,8 @@ ul.blocked li{padding:2px 0}
|
|||||||
|
|
||||||
<div class="set-group">
|
<div class="set-group">
|
||||||
<h3>Victory conditions</h3>
|
<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">
|
<div class="set-row" id="ss-minrev-row">
|
||||||
<label class="ng-gate"><input type="checkbox" id="ss-minrev-on" checked>
|
<label class="ng-gate"><input type="checkbox" id="ss-minrev-on" checked>
|
||||||
<span>You lose if Revenue at the end is under</span>
|
<span>You lose if Revenue at the end is under</span>
|
||||||
@@ -717,13 +746,13 @@ ul.blocked li{padding:2px 0}
|
|||||||
</div>
|
</div>
|
||||||
<div class="set-row" id="ss-colday-row">
|
<div class="set-row" id="ss-colday-row">
|
||||||
<label class="ng-gate"><input type="checkbox" id="ss-colday-on" checked>
|
<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>
|
<input id="ss-colday" type="number" min="0" step="1" class="gate-num"></label>
|
||||||
<span class="set-hint" id="ss-colday-hint"></span>
|
<span class="set-hint" id="ss-colday-hint"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="set-row" id="ss-coltotal-row">
|
<div class="set-row" id="ss-coltotal-row">
|
||||||
<label class="ng-gate"><input type="checkbox" id="ss-coltotal-on" checked>
|
<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>
|
<input id="ss-coltotal" type="number" min="0" step="1" class="gate-num"></label>
|
||||||
<span class="set-hint" id="ss-coltotal-hint"></span>
|
<span class="set-hint" id="ss-coltotal-hint"></span>
|
||||||
</div>
|
</div>
|
||||||
@@ -734,7 +763,7 @@ ul.blocked li{padding:2px 0}
|
|||||||
|
|
||||||
<div class="set-group">
|
<div class="set-group">
|
||||||
<h3>Optional rules</h3>
|
<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">
|
<div class="set-row" id="ss-visibility-row">
|
||||||
<label class="ng-num"><span>Reduced Visibility — five switching Moves instead of six in the
|
<label class="ng-num"><span>Reduced Visibility — five switching Moves instead of six in the
|
||||||
night Stages (1–3 and 11–12)</span>
|
night Stages (1–3 and 11–12)</span>
|
||||||
@@ -742,8 +771,7 @@ ul.blocked li{padding:2px 0}
|
|||||||
<span class="set-hint" id="ss-visibility-hint"></span>
|
<span class="set-hint" id="ss-visibility-hint"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="set-row" id="ss-rotation-row">
|
<div class="set-row" id="ss-rotation-row">
|
||||||
<label class="ng-num"><span>Employee Rotation — meaningless at a table of one, shown here so
|
<label class="ng-num"><span>Employee Rotation — not applicable for solitaire</span>
|
||||||
this screen and the lobby read as one list</span>
|
|
||||||
<input id="ss-rotation" type="checkbox" disabled></label>
|
<input id="ss-rotation" type="checkbox" disabled></label>
|
||||||
<span class="set-hint" id="ss-rotation-hint"></span>
|
<span class="set-hint" id="ss-rotation-hint"></span>
|
||||||
</div>
|
</div>
|
||||||
@@ -769,12 +797,13 @@ ul.blocked li{padding:2px 0}
|
|||||||
save (`commitNewGame` calls `clearSave`), so without a way back the door would be a way to
|
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
|
lose a game in progress — and the door is reached by clicking "Play solitaire", which nobody
|
||||||
reads as "discard what I was playing". -->
|
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
|
<p id="ss-saved-note" hidden><b>You have a solitaire game in progress.</b> Creating a new game
|
||||||
one below replaces it — there is no undo for that.</p>
|
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">
|
<menu class="ng-buttons">
|
||||||
<button id="ss-resume" type="button" hidden>Continue saved game</button>
|
<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>
|
</menu>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
@@ -810,7 +839,7 @@ ul.blocked li{padding:2px 0}
|
|||||||
</span>
|
</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="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="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>
|
<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
|
<!-- 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
|
game and I need to leave, how do I leave the game, clear the token from my browser so I can
|
||||||
@@ -891,187 +920,20 @@ ul.blocked li{padding:2px 0}
|
|||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<!-- ===================================================================
|
<!-- THE IN-GAME "NEW GAME" BUTTON GOES TO THE SOLITAIRE SETUP SCREEN — there is no second
|
||||||
NEW GAME — the seed, the opening hand, and what the three economies pay.
|
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
|
`#newgamedlg` was a third copy of the same questions, and the one that drifted: it kept the
|
||||||
you are about to play had no way in at all: the opening hand had been changed twice with no
|
multiplayer wording ("Everyone loses if COMBINED Revenue…") on a screen only ever shown to a
|
||||||
way back to the earlier rule, and the revenue rates were constants in the source. Balance is
|
solitaire player, and explained Employee Rotation in full beside a control it had disabled.
|
||||||
the open question in this game (`TODO.md`), and the way to settle it is to deal several games
|
Deleting it removes the drift rather than re-wording it.
|
||||||
at different settings — which needs a dialog, not a rebuild.
|
|
||||||
|
|
||||||
Every control has a default that is the recommended answer, so DEAL with nothing touched is a
|
Nothing is lost by navigating away mid-game: `render()` calls `save()` on every frame, so the
|
||||||
complete, sensible game. The settings ride in the URL alongside the seed, because a seed alone
|
game in progress is always on disk, and the setup screen offers "Continue saved game"
|
||||||
no longer names a game: `?seed=430` with a different opening hand is a different railroad.
|
to come straight back to it. -->
|
||||||
==================================================================== -->
|
|
||||||
</div><!-- /gameui -->
|
</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
|
<!-- 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
|
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:
|
someone reading the board notices them. A modal stops and waits, which is the whole request:
|
||||||
@@ -1088,10 +950,19 @@ ul.blocked li{padding:2px 0}
|
|||||||
<!-- THE END-OF-GAME RESULTS (Gitea#16). Filled by `resultsHtml` and opened from `renderEnding`,
|
<!-- 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:
|
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. -->
|
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">
|
<dialog id="resultsdlg" aria-labelledby="rs-title">
|
||||||
<form method="dialog">
|
<form method="dialog">
|
||||||
<div id="resultsbody"></div>
|
<div id="resultsbody"></div>
|
||||||
<menu class="ng-buttons">
|
<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>
|
<button value="ok" id="rs-ok" type="submit">Close</button>
|
||||||
</menu>
|
</menu>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
+8
-2
@@ -119,8 +119,14 @@ export const PRESETS: readonly Preset[] = [
|
|||||||
revenueFloor: (players, days) => collectiveRevenueFloor(players, days),
|
revenueFloor: (players, days) => collectiveRevenueFloor(players, days),
|
||||||
rules: {
|
rules: {
|
||||||
startingHand: SIX,
|
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,
|
passengerPerCoach: 1,
|
||||||
freightPerLoad: 1,
|
freightPerLoad: 1,
|
||||||
trainPerTransit: 0,
|
trainPerTransit: 0,
|
||||||
|
|||||||
+24
-1
@@ -655,13 +655,36 @@ describe('victory conditions (§3, Gap 10e) — unified 2026-08-20', () => {
|
|||||||
assert.notEqual(s.status, 'finished');
|
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 });
|
const s = game(1, { mode: 'solitaire', maxCollisionsPerDay: 1, maxCollisionsTotal: 1 });
|
||||||
s.collisionsToday = 99;
|
s.collisionsToday = 99;
|
||||||
s.collisionsTotal = 99;
|
s.collisionsTotal = 99;
|
||||||
s.clock.stage = 1;
|
s.clock.stage = 1;
|
||||||
s.clock.phase = 'shiftChange';
|
s.clock.phase = 'shiftChange';
|
||||||
advance(s);
|
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');
|
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('cutthroat', 4, 5).extraStart, 'anyOffice');
|
||||||
assert.equal(presetSettings('coop', 4, 5).extraStart, 'ownOffice');
|
assert.equal(presetSettings('coop', 4, 5).extraStart, 'ownOffice');
|
||||||
assert.equal(presetSettings('competitive', 4, 5).extraStart, 'ownOffice');
|
assert.equal(presetSettings('competitive', 4, 5).extraStart, 'ownOffice');
|
||||||
// At one player the two rules are the same rule.
|
// At one player the two rules ARE the same rule — `apply.ts` only rejects `ownOffice` when the
|
||||||
assert.equal(presetSettings('solitaire', 1, 5).extraStart, 'anyOffice');
|
// 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', () => {
|
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');
|
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', () => {
|
it('says nothing about a platform that is working fine', () => {
|
||||||
// Passengers waiting AND a train with an empty coach to take them: no impediment.
|
// Passengers waiting AND a train with an empty coach to take them: no impediment.
|
||||||
const s = createGame({ id: 'g', seed: 5, config, playerNames: ['p'] });
|
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]!;
|
const last = rec.frames[rec.frames.length - 1]!;
|
||||||
assert.equal(last.revenue, stats.revenue.net, 'final revenue disagrees with the engine');
|
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.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', () => {
|
it('narrates every frame', () => {
|
||||||
|
|||||||
+311
-79
@@ -21,6 +21,7 @@ import { URLSearchParams as NodeURLSearchParams } from 'node:url';
|
|||||||
import { cardDescription, cardName, describeIntent, variantLabel } from '../src/sim/view.ts';
|
import { cardDescription, cardName, describeIntent, variantLabel } from '../src/sim/view.ts';
|
||||||
import { variantsFor } from '../src/engine/track.ts';
|
import { variantsFor } from '../src/engine/track.ts';
|
||||||
import { divisionSvg, officeSvg } from '../src/sim/board-svg.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 { ENHANCEMENT_RULES, STAGES_PER_DAY } from '../src/engine/content.ts';
|
||||||
import { dayEndHtml, facilitiesHtml, resultsHtml, timetableHtml } from '../src/web/panels.ts';
|
import { dayEndHtml, facilitiesHtml, resultsHtml, timetableHtml } from '../src/web/panels.ts';
|
||||||
import { turnChartHtml } from '../src/sim/turnchart.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 { advance as advanceEngine } from '../src/engine/advance.ts';
|
||||||
import type { GameConfig } from '../src/engine/state.ts';
|
import type { GameConfig } from '../src/engine/state.ts';
|
||||||
import {
|
import {
|
||||||
|
configWith,
|
||||||
|
SOLO_CONFIG,
|
||||||
overHandLimit,
|
overHandLimit,
|
||||||
actionGroups,
|
actionGroups,
|
||||||
actionMenu,
|
actionMenu,
|
||||||
@@ -2546,6 +2549,47 @@ describe('the static build', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('names who the game is waiting on when it stops to ask them something', () => {
|
||||||
|
/**
|
||||||
|
* REPORTED BY JESSE 2026-08-30: "waiting on shows 'nobody — the Division is running itself' BUT
|
||||||
|
* the system is actually waiting on the Superintendent."
|
||||||
|
*
|
||||||
|
* `Frame.actor` carried `clock.currentActor`, which is null for the whole Mainline Phase — so a
|
||||||
|
* game stopped dead on a §8.1 clearance ruling said nobody was holding it up, while it waited on
|
||||||
|
* a named person to click. `actingPlayer` had the answer the whole time; the Frame threw it
|
||||||
|
* away. Naming them is only half of it: three different interruptions can be pending, and
|
||||||
|
* "waiting on Bob" alone is a game that looks stuck to everyone except Bob.
|
||||||
|
*/
|
||||||
|
const base = { day: 2, stage: 5, clock: '2:20', phase: 'Mainline', phaseKey: 'mainline' };
|
||||||
|
|
||||||
|
const idle = turnChartHtml({ ...base, actor: null, awaiting: null }, null, 'Bob');
|
||||||
|
assert.match(idle, /nobody — the Division is running itself/, 'an automatic phase should say so');
|
||||||
|
|
||||||
|
for (const [asks, train] of [
|
||||||
|
['a clearance ruling', 'Train 4'],
|
||||||
|
['the Yard Office offer', 'Train 7'],
|
||||||
|
['a Red Flag', 'Train X18'],
|
||||||
|
] as [string, string][]) {
|
||||||
|
const html = turnChartHtml({ ...base, actor: 1, awaiting: { asks, train } }, 'Bob', 'Bob');
|
||||||
|
assert.doesNotMatch(html, /running itself/, `"${asks}" still reported nobody`);
|
||||||
|
assert.match(html, /waiting on <b>Bob<\/b>/, `"${asks}" did not name who it waits on`);
|
||||||
|
assert.match(html, new RegExp(asks.replace(/ /g, ' ')), `"${asks}" did not say what is being asked`);
|
||||||
|
assert.match(html, new RegExp(train), `"${asks}" did not name the train it is about`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('puts the Fedora at the right-hand end of the phase row (TODO #29)', () => {
|
||||||
|
// It sat on a line of its own between the phases and everything above them, which put a thing
|
||||||
|
// that moves every third Stage in among the things that move every Stage. The row it belongs
|
||||||
|
// beside is the one whose last chip is Supervisor Shift — the phase that passes it.
|
||||||
|
const frame = { day: 1, stage: 4, clock: '2:00', phase: 'New Train', phaseKey: 'newTrain', actor: 1 };
|
||||||
|
const html = turnChartHtml(frame, 'Bob', 'Bob');
|
||||||
|
const row = /<div class="tc-row">([\s\S]*?)<\/div>\s*$/.exec(html)?.[1] ?? '';
|
||||||
|
assert.match(row, /<ol class="tc-phases">/, 'the phase row is not in the row wrapper');
|
||||||
|
assert.ok(row.indexOf('tc-super') > row.indexOf('tc-phases'), 'the Fedora is not after the phases');
|
||||||
|
assert.match(turnChartHtml(frame, 'Bob', null), /tc-row/, 'solitaire lost the row wrapper with the Fedora');
|
||||||
|
});
|
||||||
|
|
||||||
it('names the Superintendent at a table, and stays quiet about it in solitaire', () => {
|
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
|
* REPORTED BY JESSE 2026-08-23, playing two-player on StartOS: seat 1 played a train card and
|
||||||
@@ -2832,6 +2876,107 @@ describe('the Division map shows the whole route', () => {
|
|||||||
return divisionSvg(snapshot(s, [], null).division);
|
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', () => {
|
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
|
// 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.
|
// actually runs along was invisible on the only view that shows where trains are.
|
||||||
@@ -3339,6 +3484,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)', () => {
|
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
|
* A finished game, built by running the clock off the end of a real one rather than by hand — the
|
||||||
@@ -3362,6 +3535,36 @@ describe('the end-of-game results screen (Gitea#16)', () => {
|
|||||||
return snapshot(s, [], null);
|
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', () => {
|
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 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.
|
// the one moment the game has the player's whole attention.
|
||||||
@@ -3577,9 +3780,9 @@ describe('the lobby screen', () => {
|
|||||||
'lb-hand': group(['threeRandom', 'sixRandom', 'threeTrackThreeOther'], 'sixRandom'),
|
'lb-hand': group(['threeRandom', 'sixRandom', 'threeTrackThreeOther'], 'sixRandom'),
|
||||||
'lb-extra': group(['divisionPointsOnly', 'ownOffice', 'anyOffice'], 'anyOffice'),
|
'lb-extra': group(['divisionPointsOnly', 'ownOffice', 'anyOffice'], 'anyOffice'),
|
||||||
'lb-type': group(['solitaire', 'coop', 'competitive', 'cutthroat', 'custom'], 'coop'),
|
'lb-type': group(['solitaire', 'coop', 'competitive', 'cutthroat', 'custom'], 'coop'),
|
||||||
'ng-hand': group(['threeRandom', 'sixRandom', 'threeTrackThreeOther'], 'sixRandom'),
|
'ss-hand': group(['threeRandom', 'sixRandom', 'threeTrackThreeOther'], 'sixRandom'),
|
||||||
'ng-extra': group(['divisionPointsOnly', 'ownOffice', 'anyOffice'], 'anyOffice'),
|
'ss-extra': group(['divisionPointsOnly', 'ownOffice', 'anyOffice'], 'anyOffice'),
|
||||||
'ng-type': group(['solitaire', 'coop', 'competitive', 'cutthroat', 'custom'], 'solitaire'),
|
'ss-type': group(['solitaire', 'coop', 'competitive', 'cutthroat', 'custom'], 'solitaire'),
|
||||||
};
|
};
|
||||||
const matching = (sel: string): Radio[] => {
|
const matching = (sel: string): Radio[] => {
|
||||||
const name = /name="([^"]+)"/.exec(sel)?.[1] ?? '';
|
const name = /name="([^"]+)"/.exec(sel)?.[1] ?? '';
|
||||||
@@ -3703,8 +3906,13 @@ describe('the lobby screen', () => {
|
|||||||
els.get('lb-freight')!['value'] = '3';
|
els.get('lb-freight')!['value'] = '3';
|
||||||
(els.get('lb-freight')!['oninput'] as () => void)();
|
(els.get('lb-freight')!['oninput'] as () => void)();
|
||||||
assert.equal(chosen(groups, 'lb-type'), 'custom', 'changing a RULE should have selected Custom');
|
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/);
|
// The summary sentence under the radios is gone (2026-08-30). What says HOW a Custom game
|
||||||
assert.match(String(els.get('lb-type-note')!['textContent']), /1 setting differs from Co-op/);
|
// 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 () => {
|
it('clicking a type again resets every rule, and leaves the parameters alone', async () => {
|
||||||
@@ -3832,7 +4040,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.
|
* THE DRIFT GUARD.
|
||||||
*
|
*
|
||||||
@@ -3847,20 +4055,20 @@ describe('the lobby and the dialog ask the same questions', () => {
|
|||||||
return readFileSync(join(dist, 'play.html'), 'utf8');
|
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
|
// `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.
|
// block ("asking first is the only path"). Same drift guard, one more prefix.
|
||||||
const html = page();
|
const html = page();
|
||||||
for (const prefix of ['lb-', 'ng-', 'ss-']) {
|
for (const prefix of ['lb-', 'ss-']) {
|
||||||
for (const selector of fieldSelectors(prefix)) {
|
for (const selector of fieldSelectors(prefix)) {
|
||||||
assert.ok(html.includes(selector), `the ${prefix} block is missing ${selector}`);
|
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();
|
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']) {
|
for (const type of ['solitaire', 'coop', 'competitive', 'cutthroat', 'custom']) {
|
||||||
assert.ok(
|
assert.ok(
|
||||||
html.includes(`name="${prefix}type" value="${type}"`),
|
html.includes(`name="${prefix}type" value="${type}"`),
|
||||||
@@ -3887,13 +4095,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
|
// 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.
|
// set — so the checkbox could not do anything, on either screen. The fact is stated in words.
|
||||||
const html = page();
|
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="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);
|
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
|
* 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
|
* wrong here is wiring rather than logic: an id that does not match the HTML, a handler on the
|
||||||
@@ -3935,14 +4147,10 @@ describe('the New Game dialog', () => {
|
|||||||
}));
|
}));
|
||||||
// ONE set per page, not one per element: the block is addressed through the document, and a stub
|
// 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.
|
// 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[]> = {
|
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-hand': group(['threeRandom', 'sixRandom', 'threeTrackThreeOther'], 'sixRandom'),
|
||||||
'ss-extra': group(['divisionPointsOnly', 'ownOffice', 'anyOffice'], 'anyOffice'),
|
'ss-extra': group(['divisionPointsOnly', 'ownOffice', 'anyOffice'], 'anyOffice'),
|
||||||
'ss-type': group(['solitaire', 'coop', 'competitive', 'cutthroat', 'custom'], 'solitaire'),
|
'ss-type': group(['solitaire', 'coop', 'competitive', 'cutthroat', 'custom'], 'solitaire'),
|
||||||
@@ -4014,15 +4222,15 @@ describe('the New Game dialog', () => {
|
|||||||
|
|
||||||
/** What every field of the block reads, so a test can assert the whole form at once. */
|
/** 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 }[]>) => ({
|
const readForm = (els: Map<string, Record<string, unknown>>, groups: Record<string, { value: string; checked: boolean }[]>) => ({
|
||||||
hand: groups['ng-hand']!.find((r) => r.checked)?.value,
|
hand: groups['ss-hand']!.find((r) => r.checked)?.value,
|
||||||
extra: groups['ng-extra']!.find((r) => r.checked)?.value,
|
extra: groups['ss-extra']!.find((r) => r.checked)?.value,
|
||||||
type: groups['ng-type']!.find((r) => r.checked)?.value,
|
type: groups['ss-type']!.find((r) => r.checked)?.value,
|
||||||
passenger: els.get('ng-passenger')!['value'],
|
passenger: els.get('ss-passenger')!['value'],
|
||||||
freight: els.get('ng-freight')!['value'],
|
freight: els.get('ss-freight')!['value'],
|
||||||
transit: els.get('ng-transit')!['value'],
|
transit: els.get('ss-transit')!['value'],
|
||||||
days: els.get('ng-days')!['value'],
|
days: els.get('ss-days')!['value'],
|
||||||
minrev: els.get('ng-minrev')!['value'],
|
minrev: els.get('ss-minrev')!['value'],
|
||||||
tossloco: els.get('ng-tossloco')!['checked'],
|
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 () => {
|
it('opens on the rules in play, so a second game can be dealt to compare with the first', async () => {
|
||||||
@@ -4031,9 +4239,9 @@ describe('the New Game dialog', () => {
|
|||||||
const { els, groups } = await load('?seed=430&hand=sixRandom&passenger=2&freight=3&transit=4');
|
const { els, groups } = await load('?seed=430&hand=sixRandom&passenger=2&freight=3&transit=4');
|
||||||
(els.get('newgame')!['onclick'] as () => void)();
|
(els.get('newgame')!['onclick'] as () => void)();
|
||||||
|
|
||||||
const dlg = els.get('newgamedlg')!;
|
assert.equal(els.get('solitairesetup')!['hidden'], false, 'the New game button did not open the setup screen');
|
||||||
assert.equal(dlg['open'], true, 'the New game button did not open the dialog');
|
assert.equal(els.get('gameui')!['hidden'], true, 'the board is still showing over the setup screen');
|
||||||
assert.equal(els.get('ng-seed')!['value'], '', 'the seed box kept the last game’s seed');
|
assert.equal(els.get('ss-seed')!['value'], '', 'the seed box kept the last game’s seed');
|
||||||
const form = readForm(els, groups);
|
const form = readForm(els, groups);
|
||||||
assert.equal(form.passenger, '2');
|
assert.equal(form.passenger, '2');
|
||||||
assert.equal(form.freight, '3');
|
assert.equal(form.freight, '3');
|
||||||
@@ -4067,39 +4275,43 @@ describe('the New Game dialog', () => {
|
|||||||
const tuned = await load('?seed=430&transit=4');
|
const tuned = await load('?seed=430&transit=4');
|
||||||
(tuned.els.get('newgame')!['onclick'] as () => void)();
|
(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');
|
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(
|
assert.match(
|
||||||
String(tuned.els.get('ng-type-note')!['textContent']),
|
String(tuned.els.get('ss-transit-hint')!['textContent']),
|
||||||
/1 setting differs from Solitaire/,
|
/Solitaire default/,
|
||||||
'the note did not say what differs',
|
'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 () => {
|
it('offers the multiplayer types, disabled — one list across both screens, dealt from one of them', async () => {
|
||||||
const { els, groups } = await load('?seed=430');
|
const { els, groups } = await load('?seed=430');
|
||||||
(els.get('newgame')!['onclick'] as () => void)();
|
(els.get('newgame')!['onclick'] as () => void)();
|
||||||
const disabled = groups['ng-type']!
|
const disabled = groups['ss-type']!
|
||||||
.filter((r) => (r as { disabled: boolean }).disabled)
|
.filter((r) => (r as { disabled: boolean }).disabled)
|
||||||
.map((r) => r.value);
|
.map((r) => r.value);
|
||||||
assert.deepEqual(disabled, ['coop', 'competitive', 'cutthroat'], 'the wrong game types are dealable here');
|
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 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.
|
// deal, so a disabled button would be answering a question the radios no longer ask.
|
||||||
assert.ok(
|
// No reason is printed beside the dimmed rows any more (Jesse, 2026-08-30 — "grayed out with no
|
||||||
readFileSync(join(dist, 'play.html'), 'utf8').includes('id="ng-multiplayer-note"'),
|
// additional explanation"). The heading is what says which game the screen deals, once, and it
|
||||||
'nothing on the dialog says where a multiplayer game comes from',
|
// 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 () => {
|
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');
|
const { els, groups } = await load('?seed=430&transit=4');
|
||||||
(els.get('newgame')!['onclick'] as () => void)();
|
(els.get('newgame')!['onclick'] as () => void)();
|
||||||
els.get('ng-days')!['value'] = '8';
|
els.get('ss-days')!['value'] = '8';
|
||||||
(els.get('ng-days')!['oninput'] as () => void)();
|
(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
|
// 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.
|
// 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');
|
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')!;
|
const solitaire = groups['ss-type']!.find((r) => r.value === 'solitaire')!;
|
||||||
for (const r of groups['ng-type']!) r.checked = r === solitaire;
|
for (const r of groups['ss-type']!) r.checked = r === solitaire;
|
||||||
(solitaire as { onchange: (() => void) | null }).onchange!();
|
(solitaire as { onchange: (() => void) | null }).onchange!();
|
||||||
|
|
||||||
const form = readForm(els, groups);
|
const form = readForm(els, groups);
|
||||||
@@ -4112,19 +4324,17 @@ describe('the New Game dialog', () => {
|
|||||||
const { els, groups, nav: n } = await load('?seed=430');
|
const { els, groups, nav: n } = await load('?seed=430');
|
||||||
(els.get('newgame')!['onclick'] as () => void)();
|
(els.get('newgame')!['onclick'] as () => void)();
|
||||||
|
|
||||||
const dlg = els.get('newgamedlg')!;
|
els.get('ss-seed')!['value'] = '99';
|
||||||
els.get('ng-seed')!['value'] = '99';
|
for (const r of groups['ss-hand']!) r.checked = r.value === 'threeTrackThreeOther';
|
||||||
for (const r of groups['ng-hand']!) r.checked = r.value === 'threeTrackThreeOther';
|
els.get('ss-passenger')!['value'] = '5';
|
||||||
els.get('ng-passenger')!['value'] = '5';
|
els.get('ss-freight')!['value'] = '0';
|
||||||
els.get('ng-freight')!['value'] = '0';
|
els.get('ss-transit')!['value'] = '2';
|
||||||
els.get('ng-transit')!['value'] = '2';
|
els.get('ss-toolbox')!['checked'] = true;
|
||||||
els.get('ng-toolbox')!['checked'] = true;
|
(els.get('ss-deal')!['onclick'] as () => void)();
|
||||||
dlg['returnValue'] = 'deal';
|
|
||||||
(dlg['close'] as () => void)();
|
|
||||||
|
|
||||||
assert.equal(
|
assert.equal(
|
||||||
n.search,
|
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',
|
'&days=5&minrev=15&colday=3&coltotal=5&tool=1',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -4132,38 +4342,62 @@ describe('the New Game dialog', () => {
|
|||||||
it('a switched-off victory condition deals as 0, which is what the engine calls off', async () => {
|
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');
|
const { els, nav: n } = await load('?seed=430');
|
||||||
(els.get('newgame')!['onclick'] as () => void)();
|
(els.get('newgame')!['onclick'] as () => void)();
|
||||||
const dlg = els.get('newgamedlg')!;
|
els.get('ss-coltotal-on')!['checked'] = false;
|
||||||
els.get('ng-coltotal-on')!['checked'] = false;
|
(els.get('ss-coltotal-on')!['onchange'] as () => void)();
|
||||||
(els.get('ng-coltotal-on')!['onchange'] as () => void)();
|
(els.get('ss-deal')!['onclick'] as () => void)();
|
||||||
dlg['returnValue'] = 'deal';
|
|
||||||
(dlg['close'] as () => void)();
|
|
||||||
assert.match(n.search, /coltotal=0/, 'unticking the total-collision condition did not switch it off');
|
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 () => {
|
it('says the game has RESUMED, not begun, when you come back to one', 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.
|
* REPORTED BY JESSE 2026-08-30: "when you are continuing the saved game out of that screen, do
|
||||||
for (const returnValue of ['cancel', '']) {
|
* not post a message that says 'The game has begun.' … it needs to say 'The game has resumed.'"
|
||||||
const { els, nav: n } = await load('?seed=430');
|
*
|
||||||
(els.get('newgame')!['onclick'] as () => void)();
|
* A restored game draws exactly like a dealt one — mid-Day, mid-phase, log already deep — so
|
||||||
const dlg = els.get('newgamedlg')!;
|
* nothing distinguished the two. Checked in both directions here: the wording itself, and that
|
||||||
dlg['returnValue'] = returnValue;
|
* the multiplayer line which DOES say "begun" cannot be said to somebody rejoining.
|
||||||
(dlg['close'] as () => void)();
|
*/
|
||||||
assert.equal(n.search, '?seed=430', `closing with "${returnValue}" navigated`);
|
const save = JSON.stringify({ seed: 12345, history: [] });
|
||||||
assert.equal(n.reloads, 0, `closing with "${returnValue}" reloaded`);
|
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 () => {
|
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
|
// 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.
|
// search string — and assigning `location.search` the value it already holds does nothing.
|
||||||
const url =
|
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);
|
const { els, nav: n } = await load(url);
|
||||||
(els.get('newgame')!['onclick'] as () => void)();
|
(els.get('newgame')!['onclick'] as () => void)();
|
||||||
const dlg = els.get('newgamedlg')!;
|
(els.get('ss-deal')!['onclick'] as () => void)();
|
||||||
dlg['returnValue'] = 'deal';
|
|
||||||
(dlg['close'] as () => void)();
|
|
||||||
|
|
||||||
assert.equal(n.search, url, 'the URL should be unchanged — that is the whole case');
|
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');
|
assert.equal(n.reloads, 1, 'a re-deal at the same settings did nothing at all');
|
||||||
@@ -4289,14 +4523,12 @@ describe('the New Game dialog', () => {
|
|||||||
// optional box is not worth writing.
|
// optional box is not worth writing.
|
||||||
const { els, nav: n } = await load('?seed=430');
|
const { els, nav: n } = await load('?seed=430');
|
||||||
(els.get('newgame')!['onclick'] as () => void)();
|
(els.get('newgame')!['onclick'] as () => void)();
|
||||||
const dlg = els.get('newgamedlg')!;
|
els.get('ss-seed')!['value'] = 'not a number';
|
||||||
els.get('ng-seed')!['value'] = 'not a number';
|
(els.get('ss-deal')!['onclick'] as () => void)();
|
||||||
dlg['returnValue'] = 'deal';
|
|
||||||
(dlg['close'] as () => void)();
|
|
||||||
|
|
||||||
assert.equal(
|
assert.equal(
|
||||||
n.search,
|
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',
|
'a bad seed was carried into the URL',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user