Compare commits
+625
@@ -19,6 +19,631 @@ page as `v0.1.0 · <sha> · <date>`, so what is deployed can always be identifie
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 0.7.9 — 2026-08-30
|
||||||
|
|
||||||
|
Four pieces of feedback on the solitaire setup screen, plus the rules bug the second one exposed.
|
||||||
|
|
||||||
|
### The collision limits were dead settings in solitaire — now they are not
|
||||||
|
|
||||||
|
Asked to reword the collision entries to "the game ends immediately and results in a loss", which
|
||||||
|
turned out to be unwriteable: `advance.ts` gated the whole §3.4 check on
|
||||||
|
`mode === 'competitive' || mode === 'coop'`, and a solitaire game's mode is `'solitaire'`. So both
|
||||||
|
limits were offered on the New Game dialog as live settings, rode into the config, and never fired.
|
||||||
|
A solitaire player could set a limit of 1 and crash all game. The *existing* text beside them ("the
|
||||||
|
game ends in a loss") was already false; the new wording would only have made it more so.
|
||||||
|
|
||||||
|
The exclusion was never a stated rule — §3.4 does not carve solitaire out — and nothing recorded a
|
||||||
|
reason for it. **Jesse's ruling: the settings do what they say**, so the mode gate is gone rather
|
||||||
|
than the controls.
|
||||||
|
|
||||||
|
**A solitaire game can therefore now end early, and that is measured rather than asserted.** Over
|
||||||
|
200 standard games with the developer bot: `loss/collisionFloor` **1 game in 200**, with Days played
|
||||||
|
falling 5.00 → 4.98 mean and a *minimum of 1* — a bad opening Day can now end a game outright.
|
||||||
|
Collisions per game are unchanged (0.14 mean, max 3), which is the point: the ending is rare because
|
||||||
|
crashes are, not because the check is lenient. `0` still switches either limit off, at one seat
|
||||||
|
exactly as at four, and that path has its own test now.
|
||||||
|
|
||||||
|
Every figure in `TODO.md` quoted from a full-length solitaire run predates this.
|
||||||
|
|
||||||
|
### Where an Extra may start defaults to your own Control Point
|
||||||
|
|
||||||
|
Was "any player's". At one seat the two are the *same rule* — `apply.ts` only rejects `ownOffice`
|
||||||
|
when `start.seat !== seatOf(s, player)`, which cannot happen — so this is a labelling fix with no
|
||||||
|
gameplay effect and nothing to re-measure. The permissive label described a permission a lone player
|
||||||
|
was never being granted, and named an "any player" they have no contrast with.
|
||||||
|
|
||||||
|
### The extension question was hidden behind the results screen
|
||||||
|
|
||||||
|
Jesse, 2026-08-30: "Solitaire game ended. I did not have an option to extend the game by a day."
|
||||||
|
|
||||||
|
**The engine and the Frame were right the whole time** — checked before changing anything: a
|
||||||
|
solitaire game at the end of its timetable reaches `awaitingExtension` with `extensionVotes: [null]`,
|
||||||
|
and `isExtendable` covers both `revenueFloor` and `daysElapsed`. `renderActions` reaches
|
||||||
|
`renderEnding` for any non-active status, and `renderEnding` writes "play one more Day" and "end the
|
||||||
|
game here" into `#actions`.
|
||||||
|
|
||||||
|
Then it opens `#resultsdlg`, **which is modal**. So the two buttons were rendered directly underneath
|
||||||
|
a dialog whose only control was Close, and the player read a results screen that offered nothing but
|
||||||
|
Close and concluded the game was over — which is precisely what it looked like.
|
||||||
|
|
||||||
|
The results dialog now carries the question itself, hidden unless a vote is actually pending:
|
||||||
|
**Play One More Day** and **End the Game Here**, casting the same `game.extend` intent. The
|
||||||
|
`#actions` buttons stay, because they are what remains once the dialog is closed and what a player
|
||||||
|
who reopens it with "see the full results" comes back to.
|
||||||
|
|
||||||
|
**Why this survived being "verified".** `TODO.md` #35 recorded extended play as checked on
|
||||||
|
`phoenix.local` — over the HTTP API, which renders no dialog. The browser path had never been run to
|
||||||
|
the end of a timetable. Same shape as the three attempts before it: the thing that was verified was
|
||||||
|
not the thing the player uses.
|
||||||
|
|
||||||
|
### `configWith` let the day count and the Revenue floor disagree
|
||||||
|
|
||||||
|
`minCombinedRevenue` fell back to `SOLO_CONFIG`'s constant — the floor for a *five*-Day game —
|
||||||
|
whatever `days` said. So `configWith({ days: 1 })` asked a one-Day game to clear **15**, a figure a
|
||||||
|
full five-Day game averages barely half of, and `configWith({ days: 10 })` asked for the same 15 a
|
||||||
|
five-Day game does. It derives from the days it was actually given now.
|
||||||
|
|
||||||
|
Not a live fault: `createLocalSession` is the only caller, and the page always writes the floor
|
||||||
|
itself, so no dealt game was ever wrong. It was found by a throwaway probe written to reproduce the
|
||||||
|
extension bug above, which passed only `days` — which is exactly how the next caller would reach for
|
||||||
|
it. At the default day count the answer is unchanged, since `SOLO_CONFIG`'s own floor is this same
|
||||||
|
formula at `DEFAULT_DAYS`; the three new tests pin that as well as the derivation.
|
||||||
|
|
||||||
|
### A Heavy Grade shows which way it climbs
|
||||||
|
|
||||||
|
Jesse, 2026-08-30: "heavy grade mainline card tooltip states climbs east, but card doesn't show it."
|
||||||
|
The Frame has carried `gradeUp` since the Division map was rebuilt and the tip has read "climbs
|
||||||
|
east" all along — but the one Mainline card whose orientation is set per game, and the one where
|
||||||
|
Helpers and Brakeman mean opposite things at opposite ends, drew nothing to tell the two apart.
|
||||||
|
|
||||||
|
A brown wedge in the card's lower right, rising toward the climb, with a bone arrow lying along its
|
||||||
|
slope. **East is right on this map** (Gitea#18), which is what lets a wedge be read without a
|
||||||
|
compass — and is the layout decision paying for itself again.
|
||||||
|
|
||||||
|
**Four passes, and the first three are worth recording because each failed differently.** An arrow
|
||||||
|
up the hypotenuse had to start at the wedge's thin corner, where there is no height to draw in, so
|
||||||
|
its head sat over the edge and read as clipped. Level, it was contained but did not read as
|
||||||
|
climbing. At 30° — steeper than the wedge's own 22.5° — it had to be tucked into the fat half to
|
||||||
|
survive. Lying **along** the slope is the shape that fits: the perpendicular gap to the hypotenuse
|
||||||
|
is then constant down the whole arrow instead of closing at one end, so it can sit on the triangle's
|
||||||
|
centroid. The wedge grew 44×19 → 58×24 to pay for that, because a centred arrow has *less* room than
|
||||||
|
an off-centre one — the centroid is about 7px from the hypotenuse.
|
||||||
|
|
||||||
|
The final sizes are a search result rather than an eyeballed nudge: the roomiest arrow keeping all
|
||||||
|
seven vertices clear of both edges, at 2.88px.
|
||||||
|
|
||||||
|
**A test that passed a visibly broken glyph is the reason this is pinned properly.** The first
|
||||||
|
containment check bounded the arrow against the CARD, which it never left — while the wedge clipped
|
||||||
|
it. The check is against the *triangle* now, with a 2px floor, plus the arrow being parallel to the
|
||||||
|
wedge (derived from the wedge, so resizing it cannot leave a stale angle) and centred on the
|
||||||
|
centroid.
|
||||||
|
|
||||||
|
**Orientation is always set — measured, not assumed**, since a wedge that invented a direction would
|
||||||
|
be worse than no wedge. Across 400 seeds × 4 player counts: **535 Heavy Grades placed, 0 without an
|
||||||
|
orientation**, 276 east / 259 west. `setup.ts` is the only place a Mainline node is created and it
|
||||||
|
rolls the direction from the seed for every grade, so the `?? 'east'` fallbacks in `view.ts` and
|
||||||
|
`advance.ts` are unreachable.
|
||||||
|
|
||||||
|
### Two setup screens, not three — the in-game dialog is deleted
|
||||||
|
|
||||||
|
Jesse, 2026-08-30: "If you are in the Solitaire game and you click the new game dialog, it should not
|
||||||
|
go to a separate screen. We should reuse the Solitaire New Game Screen… in general we should reuse
|
||||||
|
what we already have."
|
||||||
|
|
||||||
|
`#newgamedlg` was a third copy of the same questions and the one that drifted. It was shown ONLY to
|
||||||
|
a solitaire player, and it asked "Everyone loses if **combined** Revenue at the end is under" — a
|
||||||
|
table's question, put to one person — and explained Employee Rotation in full, in multiplayer terms,
|
||||||
|
beside a control it had itself disabled. Both were on the list to re-word. Deleting the screen
|
||||||
|
removes the drift instead of restating it, which is the cheaper fix and the one that cannot drift
|
||||||
|
again.
|
||||||
|
|
||||||
|
**New game now opens the setup screen in place.** Not a navigation: the live session stays in memory,
|
||||||
|
so the fields open on the rules actually being played — which is what the dialog was good for, and
|
||||||
|
losing "change one dial, redeal, compare" would have been a real loss — and **Continue Existing Saved
|
||||||
|
Game** puts the board straight back with no reload and no replay. Nothing is at risk either way:
|
||||||
|
`render()` calls `save()` every frame, so the game in progress is always on disk.
|
||||||
|
|
||||||
|
**And the two remaining screens now match below their headers.** Each keeps its own opening — the
|
||||||
|
lobby's join/secret section and "Create a new game" are multiplayer's alone — but from the parameters
|
||||||
|
down they are one form: the same three fields in the same order (seed, players at the table, days),
|
||||||
|
the same note about a seed and settings dealing the same railroad, and the same note about every
|
||||||
|
chair being taken. Solitaire shows **Players at the table** too, locked at one, rather than omitting
|
||||||
|
it — a control that is present and fixed says "this is the same form, at a table of one", where a
|
||||||
|
missing one just made it a different form that happened to share a rules block.
|
||||||
|
|
||||||
|
The drift guard that had been checking three prefixes now checks two, and a new assertion fails if
|
||||||
|
any `ng-` id ever reappears.
|
||||||
|
|
||||||
|
### The two screens are worded the same, section by section
|
||||||
|
|
||||||
|
Jesse, 2026-08-30: "let's get the text between the Multiplayer and solitaire as close as possible to
|
||||||
|
the same." Walked section by section; where the two said the same thing differently, the lobby's
|
||||||
|
wording won, because it was written for the harder case.
|
||||||
|
|
||||||
|
- **The chair note is one paragraph on both**, replacing two that each described only their own
|
||||||
|
case: "Every chair must be filled before the game can start. For solitaire, there's only one
|
||||||
|
player. For multiplayer, that must be filled by a person or a bot. The number of players cannot be
|
||||||
|
changed once the game is created."
|
||||||
|
- **The Game type heading names which game the screen deals** — "Game type (multi-player)" and
|
||||||
|
"Game type (solitaire)". That heading is now the whole explanation for the dimmed rows.
|
||||||
|
- **No reason is printed beside a dimmed type any more.** The lobby appended "— dealt with the New
|
||||||
|
game button, not here" to Solitaire, and the solitaire screen appended "— use the Multiplayer
|
||||||
|
button; a table needs a server" to each of three. Jesse: "grayed out with no additional
|
||||||
|
explanation. The explanation above… is sufficient." One heading says it once; three rows were
|
||||||
|
saying it three times. `markUnavailable` is down to dimming, and `.lb-why` is gone with them.
|
||||||
|
- **The sentence under the radios is deleted on both.** It restated the type just chosen to the
|
||||||
|
person who had just chosen it. Its one non-obvious case — how many settings a Custom game differs
|
||||||
|
by — is not lost: `form.mark` already puts "Co-op default: …" on each row that differs, which is
|
||||||
|
where a reader can act on it rather than a count they would have to go and find. The tests that
|
||||||
|
read that sentence now read the row hints instead.
|
||||||
|
- **The Game settings, Starting hand and Victory conditions notes are the lobby's on both**, with
|
||||||
|
one edit Jesse asked for: "clicking a type again resets" → "changing the game type resets", which
|
||||||
|
names the control that does it rather than a gesture. Victory conditions was not on his list —
|
||||||
|
applied under the same rule, and the lobby's wording fits now that solitaire also shows the table
|
||||||
|
size.
|
||||||
|
|
||||||
|
What is deliberately still different: each screen's own opening paragraph, and the heading above.
|
||||||
|
|
||||||
|
### The status line said nobody was holding the game up
|
||||||
|
|
||||||
|
Jesse, 2026-08-30: "waiting on shows 'nobody — the Division is running itself' BUT the system is
|
||||||
|
actually waiting on the Superintendent."
|
||||||
|
|
||||||
|
`Frame.actor` carried `clock.currentActor`, which is null for the whole Mainline Phase — so the three
|
||||||
|
interruptions that stop the game and put a question to a named person (§8.1's clearance ruling,
|
||||||
|
Gitea#5's Yard Office offer, Gitea#19's Red Flag prompt) all reported that the Division was running
|
||||||
|
itself. `actingPlayer` has had the answer since the Gitea#5 refactor; the Frame threw it away.
|
||||||
|
|
||||||
|
It carries `actingPlayer` now, **and what the question is**: "waiting on **Bob** · a clearance ruling
|
||||||
|
· Train 4". Naming the person is not enough on its own — three different things can be pending, and
|
||||||
|
"waiting on Bob" with nothing after it is a game that looks stuck to everyone except Bob.
|
||||||
|
|
||||||
|
### The Fedora moved to the end of the phase row
|
||||||
|
|
||||||
|
`TODO.md` #29. It sat on a line of its own between the phase chips and everything above them, which
|
||||||
|
put a thing that moves every third Stage in among the things that move every Stage. It rides at the
|
||||||
|
right-hand end of the phase row now — the row whose last chip is Supervisor Shift, the phase that
|
||||||
|
passes it — and wraps underneath rather than squeezing the chips on a narrow screen.
|
||||||
|
|
||||||
|
### The developer replay stopped printing a raw enum
|
||||||
|
|
||||||
|
`TODO.md` #34. Its heading read `loss — revenueFloor`: the exact defect Gitea#16 was filed about on
|
||||||
|
the playable page, still alive here a release after that was fixed, because nothing player-facing
|
||||||
|
pointed at it. It reads "lost — The Division closed short: 3 Revenue between everyone, against a
|
||||||
|
floor of 15…" now.
|
||||||
|
|
||||||
|
`panels.ts`'s `reasonSentence` is exported and shared rather than reimplemented, so the replay and
|
||||||
|
the results screen cannot explain one ending two different ways. It is fed the last recorded frame —
|
||||||
|
the state the outcome was decided in — and stripped of markup, since it lands in an `<h1>` and a
|
||||||
|
console line. The drift test that compared this string against the engine now maps `win`/`loss` to
|
||||||
|
`won`/`lost`, so it still checks the two agree rather than that they are spelled alike.
|
||||||
|
|
||||||
|
### The save warning, the buttons, and a claim that was simply false
|
||||||
|
|
||||||
|
Three more from Jesse reading the two screens side by side.
|
||||||
|
|
||||||
|
- **The save warning is a warning.** "That's a warning, not an 'oh by the way'." It had already moved
|
||||||
|
off `.ng-note` earlier in this release; it is larger and heavier again now — 15px, weight 500,
|
||||||
|
bright amber on a deeper ground with a 5px rule down the side. Worth noting what he was actually
|
||||||
|
looking at: `phoenix.local` runs v0.7.8, where this line is still the small grey `.ng-note`. None
|
||||||
|
of this release has been deployed.
|
||||||
|
- **The buttons read the same on both screens**: **Continue saved game** and **Create new game**.
|
||||||
|
Solitaire said "Continue Existing Saved Game" and "Deal New Game"; the lobby said "Create game".
|
||||||
|
Three phrasings for two actions.
|
||||||
|
- **"Off in every game type" is deleted from the Optional rules note, because it was not true.**
|
||||||
|
Checked against the presets rather than taken on trust: `discardTimetabled` — §6.2's "a Timetabled
|
||||||
|
train may be discarded" — ships **on** in all four types, not just Co-op. The note now says only
|
||||||
|
what is true of all of them: "Each one changes how the game plays."
|
||||||
|
|
||||||
|
### A game you come back to has not begun
|
||||||
|
|
||||||
|
Jesse, 2026-08-30: "when you are continuing the saved game out of that screen, do not post a message
|
||||||
|
that says 'The game has begun.' … it needs to say 'The game has resumed.'"
|
||||||
|
|
||||||
|
A restored game draws exactly like a dealt one — mid-Day, mid-phase, with a log already several turns
|
||||||
|
deep — and **solitaire said nothing at all** to tell them apart. It flashes "The game has resumed —
|
||||||
|
Day 3, Stage 7" now, on both ways back: the setup screen's **Continue saved game**, and a bare reload
|
||||||
|
that restores the save.
|
||||||
|
|
||||||
|
**The same line was wrong on the multiplayer side, in the other direction.** `noteFirstFrame` guards
|
||||||
|
on `firstFrameSeen`, which is per page-load — so re-entering a game this browser already held a seat
|
||||||
|
in, by reloading mid-game or picking it out of the lobby's list, announced that the game had **begun**
|
||||||
|
to somebody who had been playing it for an hour. `beginRemote` carries whether this is a rejoin now,
|
||||||
|
and the line reads "resumed" when it is.
|
||||||
|
|
||||||
|
Both halves are pinned, including that a brand-new game does *not* claim to be a resume — an
|
||||||
|
announcement that fires either way says nothing.
|
||||||
|
|
||||||
|
### The screen does what you tell it — three items off `TODO.md`
|
||||||
|
|
||||||
|
Reviewed with Jesse 2026-08-30 out of the Display section. A fourth, #17 (hiding the Division map),
|
||||||
|
was **declined** in the same pass: its premise died with Gitea#18 and nobody had connected the two.
|
||||||
|
The map used to grow a row at a time and was worth folding away at three or four seats; a single row
|
||||||
|
is `boardH = PAD * 2 + CH + 30` — 150px, fixed, at every seat count — and that is not worth a
|
||||||
|
control, three states and a persisted preference.
|
||||||
|
|
||||||
|
#### The Office Area's auto-hide could not reach every state (#16)
|
||||||
|
|
||||||
|
One button cycling `auto -> pinned -> auto`, where the pin it reached was
|
||||||
|
`open ? 'closed' : 'open'` — and `open` is what auto is doing **at that moment**,
|
||||||
|
`FOCUS_PHASES.has(f.phaseKey)`. So which pin a press offered depended on the phase: "always hidden"
|
||||||
|
during Local Operations and Cargo, "always showing" everywhere else. Getting from one pin to the
|
||||||
|
other meant clicking back to auto, waiting for the phase to turn over, and clicking again. That is
|
||||||
|
why it never read as a setting — it was not one.
|
||||||
|
|
||||||
|
Three controls now, one per mode, and every mode is one press from every other. The labels still say
|
||||||
|
what pressing **does** rather than what the panel is doing, which was an earlier deliberate fix; what
|
||||||
|
the cycle could not do was report the state it was in, and `aria-pressed` on the lit button carries
|
||||||
|
that instead of the label.
|
||||||
|
|
||||||
|
**Addressed by id (`#dm-auto`/`#dm-open`/`#dm-closed`) rather than by querying the container's
|
||||||
|
children**, and that is a testability decision rather than a style one. The page never writes this
|
||||||
|
markup, so a child query finds nothing in the stubbed DOM the web suite runs against — the control
|
||||||
|
would have shipped green and completely unexercised. The test that now pins it presses
|
||||||
|
always-show → always-hide directly, which is precisely the transition the cycle could not make.
|
||||||
|
|
||||||
|
#### The history reads newest first (#23)
|
||||||
|
|
||||||
|
Jesse: "it should be reversed so the top line is the most recent and the further down you go, the
|
||||||
|
older the entry." The panel ran oldest-first and scrolled itself to the bottom, so the thing that had
|
||||||
|
just happened was the one line you had to go and find.
|
||||||
|
|
||||||
|
**The phase headings now trail their lines, and that is accepted rather than overlooked.** A
|
||||||
|
`t-phase` line reads forwards — it introduces what follows it — so reversing puts each one below the
|
||||||
|
events it announced. Jesse ruled on it directly: "stage changes will be beneath (prior to / older
|
||||||
|
than) the following events. That is OK." Reading down the panel is reading backwards in time, and a
|
||||||
|
heading under its own lines is what backwards looks like. Grouping by phase and reversing the groups
|
||||||
|
was the alternative and was declined as more machinery than the complaint needs. The
|
||||||
|
"— the game began —" marker moves by the same logic: it is the oldest thing on screen, so it goes
|
||||||
|
last.
|
||||||
|
|
||||||
|
`replays.ts` keeps its own oldest-first log deliberately. It is paired with a frame stepper, where
|
||||||
|
"what just happened" is the step you have this moment clicked, so newest-first would fight the
|
||||||
|
stepping rather than help it.
|
||||||
|
|
||||||
|
#### The settings moved into a card, and the collision counts came out of hiding (#28)
|
||||||
|
|
||||||
|
Jesse, 2026-08-23: "the game-specific information in the very top line should probably be a card like
|
||||||
|
Facilities, timetable or blocked… we can give complete information about all the game options and not
|
||||||
|
take up valuable real estate at the top of the screen." And on when it is read: "To go, 'Oh wait, what
|
||||||
|
did we set that to?'"
|
||||||
|
|
||||||
|
The top line carried six things and now carries four: **Revenue, the objective, the collision counts,
|
||||||
|
and the game code**. The seed or seat, the game type and the abbreviated house rules moved into a
|
||||||
|
**This Game** card at the foot of the right-hand column, folded by default and persisted with the
|
||||||
|
other display preferences.
|
||||||
|
|
||||||
|
**Nothing new travels for it.** `configFromFrame` already turns the Frame's copy of the config back
|
||||||
|
into a `GameConfig`, and `rulesListHtml` is the renderer the lobby's join preview and seating screen
|
||||||
|
already draw — so what a player agreed to before the deal and what they read mid-game come from one
|
||||||
|
implementation and cannot drift. The card adds only the half `rulesListHtml` has no notion of: which
|
||||||
|
seed or seat this is, and what the game is called.
|
||||||
|
|
||||||
|
**The collision counts are new on the board, not merely moved.** The Frame has carried
|
||||||
|
`collisionsToday` and `collisionsTotal` since v0.7.0 and nothing drew them, so the one victory
|
||||||
|
condition that ends a game EARLY ran invisibly — which v0.7.9 made reachable in solitaire too, and
|
||||||
|
that is what made them worth having. They stay on the top line while the limits go in the card,
|
||||||
|
because the two are different kinds of thing: a limit is a setting agreed to once, "2 of 3 today" is
|
||||||
|
a number that changes how you play the next Stage. A limit of `0` is off, and an off half is left out
|
||||||
|
rather than shown as "1 of 0"; with both off the chip is empty and collapses.
|
||||||
|
|
||||||
|
In solitaire the game-code span is empty, so its tooltip — which still carries the type, the blurb and
|
||||||
|
the victory conditions — is unreachable. Not a hole: the card's summary line is on screen whether the
|
||||||
|
card is open or shut and opens with the type, "Solitaire · 5 Days · floor 15 · 3 cards · 4/2/1".
|
||||||
|
|
||||||
|
**One gap in the test stubs was closed to get here.** None of the five element factories in the web
|
||||||
|
suite had `setAttribute`, so the first render threw `b.setAttribute is not a function` — meaning any
|
||||||
|
control that reports its state through ARIA could not be tested at all. They carry an attribute bag
|
||||||
|
now, and the segmented control's test reads `aria-pressed` through it.
|
||||||
|
|
||||||
|
**What is verified and what is not.** The logic is covered by three new tests and the two that used to
|
||||||
|
read `#houserules`/`#gametype` were rewritten to open the card rather than deleted. **The layout is
|
||||||
|
not verified** — there is no browser on this box, so nothing has confirmed the segmented control, the
|
||||||
|
card, or the reversed panel actually look right on screen. That wants a play session.
|
||||||
|
|
||||||
|
### Four things the game counted and never said
|
||||||
|
|
||||||
|
Prompted by Jesse asking the general question after two separate v0.7.9 fixes turned out to have the
|
||||||
|
same shape — `actingPlayer` existed and the Frame threw it away, and `collisionsToday`/`collisionsTotal`
|
||||||
|
rode the Frame for three releases with nothing drawing them. So: **what else is being computed,
|
||||||
|
serialised and sent to nobody?**
|
||||||
|
|
||||||
|
Audited rather than guessed. Every one of `Frame`'s 59 top-level fields grepped for a read across the
|
||||||
|
seven renderers, then the same for `Tally`'s 26 members. **55 of 59 are read.** The four that are not:
|
||||||
|
|
||||||
|
- **`tally.unloadsBegun`** — and this one was visible rather than merely unused. §9.1 makes loading
|
||||||
|
and unloading the same shape, begun then carried through, and the results screen printed "Loads
|
||||||
|
still in the pipeline" for one side and nothing for the other. It reported half of a symmetric
|
||||||
|
mechanism. There is an "Unloads still in the pipeline" line now.
|
||||||
|
- **`tally.cardsDiscarded`** — counted by the engine, listed beside "Cards drawn" and "Cards played"
|
||||||
|
without it. Gitea#9 made throwing a Timetabled train away a legal, deliberate move, so a discard is
|
||||||
|
a player CHOICE the game counted and never reported. It reports it now.
|
||||||
|
- **`viewerSeat`** and **`overHandLimit`** — deferred by Jesse. The second is the fullest version of
|
||||||
|
the shape: the engine computes it, `view.ts` puts it on the Frame, `session.ts` declares it on the
|
||||||
|
`Session` interface and implements it twice, and the only caller in the repo is its own test. Four
|
||||||
|
layers of plumbing with no consumer. Left alone for now; the decision when it comes is
|
||||||
|
delete-or-document, not a patch.
|
||||||
|
|
||||||
|
**Fixing the two turned up a third thing worth knowing.** `resultsHtml` draws
|
||||||
|
`tallyHtml(report?.tally ?? f.tally)`, and `report` is `f.official`, which the engine writes the
|
||||||
|
moment any game ends — so a finished game reports the tally frozen at the official ending, not the
|
||||||
|
live one. The first attempt at a test overrode `f.tally` alone, changed nothing on screen, and failed
|
||||||
|
for a reason that had nothing to do with the fix.
|
||||||
|
|
||||||
|
### A shouted keyword is not a sentence
|
||||||
|
|
||||||
|
`EXTRA X18 started…`, attributed to a player, rendered as `Player Solitaire eXTRA X18 started…`. The
|
||||||
|
same happened to `TRAIN 1 MADE UP` and `COLLISION`, which are shouted deliberately.
|
||||||
|
|
||||||
|
`record` folds a narration's opening word into the middle of a sentence — "Chose to draw" has to read
|
||||||
|
"Player Bob chose to draw" — and did it with a flat `charAt(0).toLowerCase()`. It now folds **only a
|
||||||
|
sentence-cased word**: `^[A-Z][a-z]`, a capital followed by a lower-case letter, which is an ordinary
|
||||||
|
word capitalised because it began a sentence and nothing else is. That also leaves `X22 Pee-Dee`
|
||||||
|
alone, which a naive "is the first letter uppercase?" test gets wrong, because `'2'.toUpperCase()`
|
||||||
|
is `'2'`.
|
||||||
|
|
||||||
|
**It had been filed under Play Balance**, where it has no business being — which is how it survived a
|
||||||
|
session that had explicitly ruled balance work out of scope. Found by reading the section it did not
|
||||||
|
belong to.
|
||||||
|
|
||||||
|
### A replayed save now narrates what the live game narrated
|
||||||
|
|
||||||
|
`fromSave`'s loop called `record(game, result.events)` with no `actor`, so every restored save, every
|
||||||
|
undo (which rebuilds through `fromSave`) and the replay viewer stripped the "Player X" prefix off
|
||||||
|
every attributed line. Live play attributes — `submit` passes `actor` — and so does multiplayer's
|
||||||
|
replay; this was the one path of the three that did not, which meant the same moves were described in
|
||||||
|
different words from the game that produced them. The fix is one argument, with `actor` already
|
||||||
|
computed on the line above.
|
||||||
|
|
||||||
|
**Why it survived two years of green tests, which is the part worth keeping.** Nothing ever compared a
|
||||||
|
`fromSave`-built log against a LIVE-played one. The single log-comparing test compares `undo`'s
|
||||||
|
rebuilt log against another `fromSave`-built log — and since `undo` itself rebuilds through
|
||||||
|
`fromSave`, the missing attribution cancelled out on both sides. The whole suite stayed green with the
|
||||||
|
bug in place, and stayed green after the fix too.
|
||||||
|
|
||||||
|
The new test plays a game, saves it, restores it, and asserts the two logs are identical. That is the
|
||||||
|
**missing direction**, not a new requirement. All three of this batch's fixes were confirmed to fail
|
||||||
|
with the fix reverted before being called done — the discipline the v0.7.5→v0.7.8 sequence bought.
|
||||||
|
|
||||||
|
### Three wording and layout fixes
|
||||||
|
|
||||||
|
- **The collision entries** on all three screens now read "The game ends immediately and results in
|
||||||
|
a loss…". The Revenue floor keeps its own wording, since it is settled at the end rather than on
|
||||||
|
the spot.
|
||||||
|
- **Employee Rotation** on the solitaire screen says "not applicable for solitaire" instead of
|
||||||
|
"meaningless at a table of one, shown here so this screen and the lobby read as one list" — which
|
||||||
|
explained the page's own construction to somebody who had not asked.
|
||||||
|
- **The save warning is legible.** It sat in `.ng-note`, the same dim 11px grey as the twenty
|
||||||
|
explanatory notes above it, while being the only thing on the screen describing something
|
||||||
|
irreversible. It is 14px on an amber panel now — amber and not red because losing a save is a real
|
||||||
|
cost, not a danger, and red would outrank the actual rules above it. The buttons say **Continue
|
||||||
|
Existing Saved Game** and **Deal New Game** rather than "Continue saved game" and "Deal".
|
||||||
|
|
||||||
|
884 tests pass, seventeen of them new; one existing test asserted the opposite of the collision ruling
|
||||||
|
above and says so where it was reversed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0.7.8 — 2026-08-30
|
||||||
|
|
||||||
|
### The setup screen was unreachable for anyone who had ever played
|
||||||
|
|
||||||
|
Third report of the same symptom, and this time the build was confirmed current on screen
|
||||||
|
(`0.7.7-mtf7hyxc`), which ruled out the caching fault v0.7.7 had just fixed and left the actual
|
||||||
|
cause with nowhere to hide.
|
||||||
|
|
||||||
|
**v0.7.5 skipped the setup screen whenever `load()` found a save**, reasoned in its own comment as
|
||||||
|
"a saved game is a game to resume". The consequence went unnoticed: a browser that has ever played
|
||||||
|
solitaire *always* has a save, so the door could never reach the screen again. Only a browser that
|
||||||
|
had never played would see it — which is exactly why a fresh private window appeared to prove
|
||||||
|
v0.7.7's cache fix. The private window had no save. Two genuine faults were stacked, the caching one
|
||||||
|
was real and is fixed, and it masked this one.
|
||||||
|
|
||||||
|
**The door now outranks a saved game.** `?solitaire` is an explicit request to set a game up;
|
||||||
|
clicking "Play solitaire" is not a request to resume. A BARE reload still resumes, which is the
|
||||||
|
zero-friction case D11 is about and is pinned by its own test.
|
||||||
|
|
||||||
|
**Dealing from the door would have destroyed a game in progress**, since `commitNewGame` calls
|
||||||
|
`clearSave()` — so the screen now carries `#ss-resume` ("Continue saved game") and states plainly
|
||||||
|
that dealing replaces the save. Resuming navigates to the bare URL rather than building a session
|
||||||
|
on the spot, so `start()` stays the only place that turns a URL into a game.
|
||||||
|
|
||||||
|
**Recorded because the failure was diagnostic, not technical.** The first two attempts each fixed
|
||||||
|
something real that was not this, and both were reported as verified. The routing fix in v0.7.6 was
|
||||||
|
verified by reading what the server served; v0.7.7's by the same. Neither ever exercised the actual
|
||||||
|
path with the actual state a returning player has. The reproduction here is a failing test asserting
|
||||||
|
the door with a save present — written before the fix, and it failed with "a saved game swallowed
|
||||||
|
the door".
|
||||||
|
|
||||||
|
### The splash footer names both ways to play
|
||||||
|
|
||||||
|
Was "solitaire runs entirely in your browser — no server code required", written when solitaire was
|
||||||
|
the only door. Now: "Multiplayer runs on StartOS server. Solitaire runs entirely in your browser."
|
||||||
|
(Jesse, 2026-08-30, asked to ride along with the next change rather than take a release of its own.)
|
||||||
|
|
||||||
|
868 tests pass, four of them new.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0.7.7 — 2026-08-30
|
||||||
|
|
||||||
|
### Two releases shipped to a browser that never received them
|
||||||
|
|
||||||
|
Jesse installed v0.7.5, clicked **Play solitaire**, and landed in a dealt game instead of the new
|
||||||
|
setup screen. v0.7.6 diagnosed that as a routing bug, fixed it, installed, verified — and it happened
|
||||||
|
again, identically. The second report is what made the real cause findable: the fix was correct both
|
||||||
|
times and neither one ever reached the browser.
|
||||||
|
|
||||||
|
**`buildStamp()`'s no-git fallback was the literal `nogit`, and the `.s9pk` build has no git.** The
|
||||||
|
Dockerfile copies the working tree in without `.git`, so `git rev-parse` fails there on every
|
||||||
|
packaged build — and that string is not only the visible stamp, it is the cache-bust key every module
|
||||||
|
URL carries. So v0.7.4, v0.7.5 and v0.7.6 all published `./web/main.js?v=nogit`, byte-identical, and
|
||||||
|
a returning player's browser correctly concluded it had the file already. The fallback is now the
|
||||||
|
package version plus the build's own timestamp, which is always distinct and needs nothing from the
|
||||||
|
environment. Proven rather than assumed: two builds of an identical git-less tree now stamp
|
||||||
|
`0.7.6-mtf6l8rm` and `0.7.6-mtf6lant`.
|
||||||
|
|
||||||
|
**And the server sent no `Cache-Control` at all**, which is the other half — the pages are the one
|
||||||
|
thing that cannot be versioned in their own URL, since a player types the address or follows a
|
||||||
|
bookmark, so a cached `play.html` pins that player to the whole build it names including every `?v=`
|
||||||
|
inside it. Fixed the exact way round that matters: a request carrying `?v=` may be stored for a year
|
||||||
|
and marked `immutable`, and anything else is `no-cache`. `?v=` rather than "not HTML" because
|
||||||
|
`build-web.ts` tags the modules and nothing else — a year of `immutable` on an untagged image or on
|
||||||
|
the replay manifest would outlive several releases of it.
|
||||||
|
|
||||||
|
Neither half is sufficient alone: without the varying tag there is nothing for a fresh page to point
|
||||||
|
at, and without the header the fresh page is itself served from cache.
|
||||||
|
|
||||||
|
**What this says about the two releases before it.** v0.7.5's setup screen and v0.7.6's door fix were
|
||||||
|
both real, both correct, and both verified on `phoenix.local` by reading what the server served —
|
||||||
|
which was true, and was never the thing in doubt. What went unverified was the browser, and a
|
||||||
|
hard-reload would have told us on the first report. Worth remembering the next time a fix "has had no
|
||||||
|
effect": check that it arrived before re-diagnosing it.
|
||||||
|
|
||||||
|
864 tests pass, two of them new — one pinning the no-git fallback as something that varies per build,
|
||||||
|
one pinning the header rule and that the `?v=` flag actually reaches `serveStatic`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0.7.6 — 2026-08-29
|
||||||
|
|
||||||
|
### The solitaire door could not reach solitaire
|
||||||
|
|
||||||
|
Found by Jesse verifying v0.7.5 on `phoenix.local`: from a browser that had ever held a multiplayer
|
||||||
|
seat, clicking **Play solitaire** on the splash landed straight in a Co-op, four-seat lobby left over
|
||||||
|
from unrelated earlier testing — not the new setup screen v0.7.5 just shipped.
|
||||||
|
|
||||||
|
`start()` checks a browser-remembered multiplayer session (`station-master.remote.v1`) before it ever
|
||||||
|
looks at solitaire's own state, and there was nothing distinguishing "clicked Play solitaire" from
|
||||||
|
"reloaded mid multiplayer game" — a bare `./play.html` load means both. `?lobby` already solved the
|
||||||
|
identical problem for the door on the other side (D11); the solitaire door had no equivalent marker.
|
||||||
|
|
||||||
|
The door now links to `./play.html?solitaire`, and `start()` treats that — along with an explicit
|
||||||
|
`?seed=` or a `?hand=` the setup screen's own Deal button just wrote — as unambiguous proof this
|
||||||
|
navigation means solitaire, checked ahead of the remembered-session lookup rather than only below it.
|
||||||
|
The `hand` check matters on its own: without it, pressing Deal would work once and then bounce the
|
||||||
|
very next load into the remembered game, since `commitNewGame`'s URL carries `hand=` but not
|
||||||
|
`solitaire=`.
|
||||||
|
|
||||||
|
862 tests pass, three of them new: the door reaching solitaire past a remembered game, a bare reload
|
||||||
|
still correctly resuming one (unchanged behaviour, pinned so the fix does not overreach), and Deal's
|
||||||
|
own URL surviving the same bounce.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0.7.5 — 2026-08-29
|
||||||
|
|
||||||
|
### Solitaire asks first, the same way multiplayer already does
|
||||||
|
|
||||||
|
Jesse: "let the user choose their options like the start of a multiplayer game"; "asking first is
|
||||||
|
the only path." A bare visit to `play.html` used to deal a game on the spot, at whatever defaults
|
||||||
|
`gameOptionsFromUrl` fell back to, and the only way to see or change a setting was to open the
|
||||||
|
in-game "New game" dialog after the fact — compare a hand you already have, not one you are about
|
||||||
|
to be dealt. The lobby has asked this question for every multiplayer game since v0.6.0; solitaire
|
||||||
|
never did.
|
||||||
|
|
||||||
|
A genuinely fresh visit now lands on a new `#solitairesetup` screen first: game type, starting hand,
|
||||||
|
where an Extra may start, the three revenue rates, victory conditions, and the three optional rules
|
||||||
|
— then a Deal button. A saved game, an explicit `?seed=`, or a URL a Deal already wrote (`hand` is
|
||||||
|
the field every write always sets, so its presence is what tells the difference) all skip straight
|
||||||
|
past it, the same way `?lobby` already skips the front doors on an invite link — those are not "no
|
||||||
|
plan yet", they are a choice already made, elsewhere.
|
||||||
|
|
||||||
|
**One shared block instead of two copies drifting apart.** The in-game dialog, the lobby, and now
|
||||||
|
this screen all drive the identical `settings-form.ts` block through one new function,
|
||||||
|
`wireGameTypeBlock()` — factored out of what used to be dialog-only code. Only Solitaire can be
|
||||||
|
dealt outside the lobby, so the setup screen shows the other four types exactly as the dialog always
|
||||||
|
has: present, disabled, with a note pointing at the Multiplayer door. Committing an answer — from
|
||||||
|
either the dialog or the setup screen — goes through one `commitNewGame()`, which builds the URL and
|
||||||
|
navigates; `start()` is still the only place that turns a URL into a game.
|
||||||
|
|
||||||
|
Prefilling is deliberately left to the caller rather than folded into `wireGameTypeBlock` itself:
|
||||||
|
the dialog opens on the game CURRENTLY IN PLAY, so redealing to compare keeps comparing against it,
|
||||||
|
while the setup screen opens on the plain Solitaire defaults, since there is no live game yet to
|
||||||
|
read.
|
||||||
|
|
||||||
|
`index.html`'s door copy changed to match: "Start a game" reads "Set up a game" now, and the blurb
|
||||||
|
states the floor (15, not "20 Revenue") since that is what a player is agreeing to before they deal.
|
||||||
|
|
||||||
|
859 tests pass. **Not yet played in a browser** — verified by `tsc --noEmit`, the full suite, and
|
||||||
|
reading the diff, not by loading `play.html` fresh and clicking through it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0.7.4 — 2026-08-29
|
||||||
|
|
||||||
|
Three rules issues off the tracker, in the order Jesse asked for them: #13, #5, #19. All three are
|
||||||
|
rules Jesse has designed or redefined, and two of them turned out to be rules the code claimed to
|
||||||
|
have and did not.
|
||||||
|
|
||||||
|
### Some Extras must run loaded (Gitea#13)
|
||||||
|
|
||||||
|
X17 Campaign, X18 Circus and X19 Military now prefer loaded cars at make-up — "if not loaded, then
|
||||||
|
empty, and if none available, run without". It is a preference ORDER, so the rule is asked of the
|
||||||
|
Division Yard: an empty is refused only while the yard can still supply a loaded car this train
|
||||||
|
would take, per category, and once it cannot the empty is legal and the train may depart short.
|
||||||
|
|
||||||
|
The per-stop point is **once per Office Area** rather than once per game (Jesse: "in a multiplayer
|
||||||
|
game, each player could score if the circus stops in their area"), and only when the train is fully
|
||||||
|
loaded — every non-caboose car, with a coach counting as loaded when occupied. That last detail is
|
||||||
|
what makes the rule work for the Campaign Train, which carries one coach and no freight, so "fully
|
||||||
|
loaded" is exactly "the candidate is aboard". X17 also GAINS the point; it had `stopThenExpedite`
|
||||||
|
and no scoring rule at all.
|
||||||
|
|
||||||
|
**Two bugs found doing it.** `ConsistSpec.emptiesOnly` was declared on X13 Appleseed, rendered to
|
||||||
|
the player as "(empties only)", and enforced nowhere — the same rule as this issue pointing the
|
||||||
|
other way, so it would have been perverse to add one and leave the other. And a set-up out on the
|
||||||
|
Mainline paid its point to PLAYER 0 whoever was playing, because `playerAtSeat` needs a seat and off
|
||||||
|
the grid the fallback was `0`; scoping the rule to Office Areas removes that rather than patching it.
|
||||||
|
|
||||||
|
### The Yard Office is offered, reachable, and can be run into (Gitea#5)
|
||||||
|
|
||||||
|
It was implemented, in a form missing all three of the rule's conditions: a qualifying train was
|
||||||
|
TELEPORTED onto the card. Nobody was asked, no route was computed — so the card's own printed "that
|
||||||
|
can reach the yard office in one move" was unenforced — and because nothing was walked, nothing was
|
||||||
|
ever met on the way in.
|
||||||
|
|
||||||
|
Now it is offered to the district's owner; reachability is the engine's own move walk, which already
|
||||||
|
means what the card means; and cars on the lead collide. The walk COUPLES standing cars rather than
|
||||||
|
treating them as obstacles, because that is what a switching move does — so what an arriving train
|
||||||
|
would have coupled is what it is about to hit, and `destination.couples` turned out to be the
|
||||||
|
fouling signal with no new machinery. Per Jesse's ruling the two failures are kept apart: no route
|
||||||
|
means no offer and the history says why; a route that exists but is fouled is offered, and taking it
|
||||||
|
crashes.
|
||||||
|
|
||||||
|
**This needed a refactor that #19 then reused.** `pendingDecision` was one question asked of one
|
||||||
|
player, and `currentActor` hardcoded that. It is a discriminated union now, with `decisionActor` as
|
||||||
|
the single place mapping a question to whoever answers it; six copies of `pendingDecision !== null ?
|
||||||
|
superintendent : currentActor` across engine, sim, web and tests collapse into `actingPlayer`.
|
||||||
|
|
||||||
|
### Red Flags, redesigned (Gitea#19)
|
||||||
|
|
||||||
|
The old card protected a stopped train out on the Mainline: offered 4,212 times and played 4 across
|
||||||
|
600 games. It is replaced outright by a flag planted on one side of your own Limits, holding the
|
||||||
|
next train from that direction — "Flag East holds westbound trains" — spent on the train it stops.
|
||||||
|
One card, one train, so there is no lifting action to build and a flag cannot strangle the Division.
|
||||||
|
|
||||||
|
It can also be played **out of phase**: when an arrival would certainly collide and the district's
|
||||||
|
owner holds the card, the phase breaks in with the question. Offered only to somebody holding one,
|
||||||
|
because a prompt with a single button is not a choice and would leak that a collision is coming.
|
||||||
|
|
||||||
|
**The bot still never plays it, and the reason changed — measured, not assumed.** It now takes the
|
||||||
|
out-of-phase prompt unconditionally, since the engine has already established the danger. Over 200
|
||||||
|
solitaire games it plays ZERO, because the prompt needs an arrival that would collide (about one
|
||||||
|
game in seven) to coincide with holding the card from a three-card hand out of 121. The anomaly
|
||||||
|
exemption in `sim.test.ts` stays, but its comment no longer claims the bot is unwilling; what is
|
||||||
|
left to fix is the half of the card a human uses — planting a flag on purpose to buy switching time.
|
||||||
|
|
||||||
|
**A bug the redesign walked into**, recorded because the next interruption will meet it too: the
|
||||||
|
flag was originally taken down in a `reduce` case, which never fires for an event `advance.ts`
|
||||||
|
emits — the phase driver mutates and then describes. The flag stayed up and held every train that
|
||||||
|
came. `test/events.test.ts`'s unreduced-event registry is what makes that class of mistake visible,
|
||||||
|
and `redFlagSpent` is now on it deliberately.
|
||||||
|
|
||||||
|
858 tests pass.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 0.7.3 — 2026-08-29
|
## 0.7.3 — 2026-08-29
|
||||||
|
|
||||||
Two issues off the tracker, and they are halves of one thing: the end of a game. Gitea#11 stops the
|
Two issues off the tracker, and they are halves of one thing: the end of a game. Gitea#11 stops the
|
||||||
|
|||||||
@@ -132,6 +132,13 @@ is the thing this machinery exists to prevent.
|
|||||||
roughly a third of the event types are never reduced at all. Anything that needs to rebuild a game
|
roughly a third of the event types are never reduced at all. Anything that needs to rebuild a game
|
||||||
replays the intents.
|
replays the intents.
|
||||||
- **Never call `Math.random()`.** One ambient random call silently breaks replay.
|
- **Never call `Math.random()`.** One ambient random call silently breaks replay.
|
||||||
|
- **The Mainline Phase can stop and ask, and there are three questions it asks.** §8.1's clearance
|
||||||
|
ruling goes to the Superintendent; the Yard Office offer and the Red Flag prompt go to the owner of
|
||||||
|
the district a train is arriving at. `pendingDecision` is a discriminated union and `decisionActor`
|
||||||
|
is the single place that maps a question to whoever must answer it — a new question adds a case
|
||||||
|
there and nowhere else. **Ask before the move is committed:** returning `needsClearance` unwinds
|
||||||
|
the whole phase and the driver re-enters from the top, so anything already mutated is applied
|
||||||
|
twice or left half-done.
|
||||||
- **A game ends by PAUSING, and the first ending is the real one.** Running out of Days, or closing
|
- **A game ends by PAUSING, and the first ending is the real one.** Running out of Days, or closing
|
||||||
short of the combined Revenue floor, puts the game in `awaitingExtension` rather than `finished`:
|
short of the combined Revenue floor, puts the game in `awaitingExtension` rather than `finished`:
|
||||||
the table is asked whether to play one more Day, unanimously, and asked again at the end of every
|
the table is asked whether to play one more Day, unanimously, and asked again at the end of every
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "station-master",
|
"name": "station-master",
|
||||||
"version": "0.7.3",
|
"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",
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1389,6 +1389,11 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "loadUnload.end"
|
"type": "loadUnload.end"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "game.extend",
|
||||||
|
"player": 0,
|
||||||
|
"agree": false
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
+15
-1
@@ -60,7 +60,21 @@ execFileSync(
|
|||||||
*/
|
*/
|
||||||
function buildStamp(): string {
|
function buildStamp(): string {
|
||||||
const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) as { version: string };
|
const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) as { version: string };
|
||||||
let git = 'nogit';
|
/**
|
||||||
|
* THE FALLBACK HAS TO BE UNIQUE PER BUILD, because this string is also the cache-bust key.
|
||||||
|
*
|
||||||
|
* It used to be the literal `nogit`, which is exactly what the `.s9pk` build produces — the
|
||||||
|
* Dockerfile copies the working tree in without `.git`, so `git rev-parse` fails there every time.
|
||||||
|
* Every packaged release therefore published `?v=nogit`, byte-identical to the release before it,
|
||||||
|
* and a returning player's browser had no reason to refetch a single module. v0.7.5's setup screen
|
||||||
|
* and v0.7.6's fix to it both shipped correctly to `phoenix.local` and neither reached the browser
|
||||||
|
* that asked for them (Jesse, twice, 2026-08-29 — "setup did not work").
|
||||||
|
*
|
||||||
|
* The version plus the build's own timestamp is always distinct, needs nothing from the
|
||||||
|
* environment, and stays honest: two builds of the same commit ARE two deploys, and a cache key
|
||||||
|
* that says so costs one refetch, while one that lies costs a release nobody receives.
|
||||||
|
*/
|
||||||
|
let git = `${pkg.version}-${Date.now().toString(36)}`;
|
||||||
try {
|
try {
|
||||||
const sha = execFileSync('git', ['rev-parse', '--short', 'HEAD'], { cwd: root })
|
const sha = execFileSync('git', ['rev-parse', '--short', 'HEAD'], { cwd: root })
|
||||||
.toString()
|
.toString()
|
||||||
|
|||||||
+341
-42
@@ -36,10 +36,11 @@ import type { Direction, MainlineEntry, MainlineKind } from './content.ts';
|
|||||||
import type { GameEvent } from './events.ts';
|
import type { GameEvent } from './events.ts';
|
||||||
// `trainNeedingCars` lives in apply.ts beside `check`'s copy of the same question, so the phase and
|
// `trainNeedingCars` lives in apply.ts beside `check`'s copy of the same question, so the phase and
|
||||||
// the legality test cannot disagree about which train is being assembled.
|
// the legality test cannot disagree about which train is being assembled.
|
||||||
import { areaAtSeat, areaOf, trainNeedingCars } from './apply.ts';
|
import { areaAtSeat, areaOf, occupancyFor, trainNeedingCars } from './apply.ts';
|
||||||
import { legalActions } from './legal.ts';
|
import { legalActions } from './legal.ts';
|
||||||
import type { CrewTray, DivisionNode, GameState, Outcome, PlayerIndex, RollingStock, SeatIndex, TrayId } from './state.ts';
|
import type { CrewTray, DivisionNode, GameState, GridCoord, Outcome, PlayerIndex, RollingStock, SeatIndex, TrayId } from './state.ts';
|
||||||
import { cloneTally, coordKey, freshTurns, isExtendable, playerAtSeat, playerLeftOf, pooled, subdivisions, totalRevenue, turnOf } from './state.ts';
|
import { cloneTally, coordKey, freshTurns, isExtendable, playerAtSeat, playerLeftOf, pooled, railFacingOf, subdivisions, totalRevenue, turnOf } from './state.ts';
|
||||||
|
import { reachableDestinations } from './track.ts';
|
||||||
import { tallyEvent } from './tally.ts';
|
import { tallyEvent } from './tally.ts';
|
||||||
|
|
||||||
export type AdvanceResult = {
|
export type AdvanceResult = {
|
||||||
@@ -50,6 +51,31 @@ export type AdvanceResult = {
|
|||||||
|
|
||||||
const NIGHT_STAGES = new Set([1, 2, 3, 11, 12]);
|
const NIGHT_STAGES = new Set([1, 2, 3, 11, 12]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* IS EVERY CAR ON THIS TRAIN LOADED? (Gitea#13)
|
||||||
|
*
|
||||||
|
* "You only get credit for a circus or campaign train (one point per stop) if you have it fully
|
||||||
|
* loaded. Not much of a circus if all the cars are empty."
|
||||||
|
*
|
||||||
|
* A COACH COUNTS AS LOADED WHEN IT IS OCCUPIED, which is what makes this the right test for the
|
||||||
|
* Campaign Train: X17 carries one coach and no freight, so "fully loaded" is precisely "the
|
||||||
|
* candidate is aboard" (Jesse's ruling, 2026-08-29). The engine already models an occupied coach
|
||||||
|
* as `loaded`, so no second notion is introduced here.
|
||||||
|
*
|
||||||
|
* A CABOOSE IS EXEMPT, and it costs nothing to say so: every caboose in `ROLLING_STOCK_SUPPLY` is
|
||||||
|
* minted `loaded: true` — there is no empty one — so including it would change no outcome today.
|
||||||
|
* It is excluded anyway because a caboose is crew space rather than payload, and a supply table
|
||||||
|
* that grew an empty caboose should not silently start voiding circus points.
|
||||||
|
*
|
||||||
|
* AN EMPTY TRAIN IS NOT FULLY LOADED. A Circus that departed short and carries nothing at all earns
|
||||||
|
* nothing: `every` on an empty list is vacuously true, which would pay the emptiest train of the
|
||||||
|
* lot, so the length is tested first.
|
||||||
|
*/
|
||||||
|
function fullyLoaded(tray: CrewTray): boolean {
|
||||||
|
const payload = tray.consist.filter((c) => c.type !== 'caboose');
|
||||||
|
return payload.length > 0 && payload.every((c) => c.loaded);
|
||||||
|
}
|
||||||
|
|
||||||
function movesForStage(s: GameState): number {
|
function movesForStage(s: GameState): number {
|
||||||
return s.config.optionalRules.reducedVisibility && NIGHT_STAGES.has(s.clock.stage)
|
return s.config.optionalRules.reducedVisibility && NIGHT_STAGES.has(s.clock.stage)
|
||||||
? MOVES_PER_LOCAL_OPS_NIGHT
|
? MOVES_PER_LOCAL_OPS_NIGHT
|
||||||
@@ -420,33 +446,45 @@ function mainlinePhase(s: GameState, events: GameEvent[]): AdvanceResult {
|
|||||||
const where = tray.position;
|
const where = tray.position;
|
||||||
const moved = moveTrain(s, id, tray, events);
|
const moved = moveTrain(s, id, tray, events);
|
||||||
/**
|
/**
|
||||||
* X18 CIRCUS TRAIN — "one turn stopped on any track (circus set-up) earns 1 point".
|
* X18 CIRCUS / X17 CAMPAIGN — a Stage spent set up in somebody's Office Area earns a point.
|
||||||
*
|
*
|
||||||
* The flag was declared on the profile and read NOWHERE, so the one card in the deck that pays
|
* The flag was declared on the profile and read NOWHERE, so the one card in the deck that paid
|
||||||
* for standing still paid nothing: reported from a playtest where TX18 sat on a siding for a
|
* for standing still paid nothing: reported from a playtest where TX18 sat on a siding for a
|
||||||
* full Stage and no point arrived. Claimed once — an Extra runs once and is gone.
|
* full Stage and no point arrived.
|
||||||
|
*
|
||||||
|
* ONCE PER OFFICE AREA (Gitea#13, Jesse 2026-08-29): "once per stop in an office area. In a
|
||||||
|
* multiplayer game, each player could score if the circus stops in their area." So a Circus
|
||||||
|
* touring three districts is paid three times and one parked in the same district all game is
|
||||||
|
* paid once, which `stopPointSeats` records per seat.
|
||||||
|
*
|
||||||
|
* ONLY IN AN OFFICE AREA. It used to pay for standing on the Mainline or at a Division Point
|
||||||
|
* too, and misattributed both: `playerAtSeat` needs a seat, and off the grid there is none, so
|
||||||
|
* the fallback handed the point to PLAYER 0 wherever the train happened to be. Scoping the rule
|
||||||
|
* to Office Areas is what Jesse's ruling says and it removes that bug rather than patching it.
|
||||||
|
*
|
||||||
|
* FULLY LOADED, or nothing. "Not much of a circus if all the cars are empty" — see
|
||||||
|
* `fullyLoaded` below for what that means for a train whose only car is a coach.
|
||||||
*
|
*
|
||||||
* "Stopped" is measured against the Mainline Phase: the train attempted to move and stayed where
|
* "Stopped" is measured against the Mainline Phase: the train attempted to move and stayed where
|
||||||
* it was. A train that is still in the district when the phase runs has not moved either, which
|
* it was. A train that is still in the district when the phase runs has not moved either, which
|
||||||
* is the circus setting up on a siding rather than crossing the Division.
|
* is the circus setting up on a siding rather than crossing the Division.
|
||||||
*/
|
*/
|
||||||
if (!tray.stopPointClaimed && trainProfile(tray.trainNumber ?? 0, tray.trainIsExtra)?.rules.stopEarnsPoint) {
|
if (trainProfile(tray.trainNumber ?? 0, tray.trainIsExtra)?.rules.stopEarnsPoint) {
|
||||||
const stillThere =
|
const stillThere =
|
||||||
tray.position.at === where.at &&
|
tray.position.at === where.at &&
|
||||||
(tray.position.at !== 'mainline' || where.at !== 'mainline' || tray.position.index === where.index) &&
|
(tray.position.at !== 'mainline' || where.at !== 'mainline' || tray.position.index === where.index) &&
|
||||||
(tray.position.at !== 'grid' ||
|
(tray.position.at !== 'grid' ||
|
||||||
where.at !== 'grid' ||
|
where.at !== 'grid' ||
|
||||||
(tray.position.coord.row === where.coord.row && tray.position.coord.col === where.coord.col));
|
(tray.position.coord.row === where.coord.row && tray.position.coord.col === where.coord.col));
|
||||||
if (stillThere) {
|
// Bound as one value so the grid case narrows: `tray.position` is a union, and testing a
|
||||||
tray.stopPointClaimed = true;
|
// `seat` extracted from it does not tell the compiler which member it came from.
|
||||||
|
const at = tray.position.at === 'grid' ? tray.position : null;
|
||||||
|
const alreadyPaidHere = at !== null && (tray.stopPointSeats ?? []).includes(at.seat);
|
||||||
|
if (stillThere && at !== null && !alreadyPaidHere && fullyLoaded(tray)) {
|
||||||
|
tray.stopPointSeats = [...(tray.stopPointSeats ?? []), at.seat];
|
||||||
// The point goes to whoever is SITTING in the district it stopped in.
|
// The point goes to whoever is SITTING in the district it stopped in.
|
||||||
const owner = tray.position.at === 'grid' ? playerAtSeat(s, tray.position.seat) : 0;
|
const owner = playerAtSeat(s, at.seat);
|
||||||
const label =
|
const label = `(${at.coord.col},${at.coord.row})`;
|
||||||
tray.position.at === 'grid'
|
|
||||||
? `(${tray.position.coord.col},${tray.position.coord.row})`
|
|
||||||
: tray.position.at === 'mainline'
|
|
||||||
? `Mainline card ${tray.position.index}`
|
|
||||||
: `the ${tray.position.side} Division Point`;
|
|
||||||
events.push({ type: 'trainStoodStill', trainNumber: tray.trainNumber ?? 0, where: label });
|
events.push({ type: 'trainStoodStill', trainNumber: tray.trainNumber ?? 0, where: label });
|
||||||
const p = s.players[owner];
|
const p = s.players[owner];
|
||||||
if (p) {
|
if (p) {
|
||||||
@@ -456,7 +494,7 @@ function mainlinePhase(s: GameState, events: GameEvent[]): AdvanceResult {
|
|||||||
player: owner,
|
player: owner,
|
||||||
delta: 1,
|
delta: 1,
|
||||||
total: p.revenue,
|
total: p.revenue,
|
||||||
reason: 'circus set-up — a Stage spent standing still',
|
reason: 'set up in the district — a Stage spent standing still, fully loaded',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -928,9 +966,30 @@ function moveTrain(s: GameState, id: TrayId, tray: CrewTray, events: GameEvent[]
|
|||||||
// Off the end of the card: into the adjoining Limit, then straight to the Office (§8.2).
|
// Off the end of the card: into the adjoining Limit, then straight to the Office (§8.2).
|
||||||
const target = index + dir;
|
const target = index + dir;
|
||||||
const dest = s.division.nodes[target];
|
const dest = s.division.nodes[target];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* §11 (Gitea#5) — the Yard Office offer is put BEFORE the train leaves the Mainline card, for
|
||||||
|
* the same reason §8.1's clearance is: `needsClearance` unwinds the whole phase and the driver
|
||||||
|
* re-enters here from the top, so anything already mutated is mutated twice or, worse, left
|
||||||
|
* half-applied. Asking after the `transits` filter below cost the train its place on the card
|
||||||
|
* and it was never seen again — the question was asked and the answer had nowhere to land.
|
||||||
|
*/
|
||||||
|
if (dest?.kind === 'office') {
|
||||||
|
/**
|
||||||
|
* §Q, RED FLAGS (Gitea#19) — asked and answered before the train leaves the card, for exactly
|
||||||
|
* the reason the Yard Office offer is (see below): `needsClearance` unwinds the phase.
|
||||||
|
*
|
||||||
|
* Order matters. A flag stops the train OUTSIDE the Limits, so it never reaches the point
|
||||||
|
* where the Yard Office would be offered — flagging is about keeping a train out altogether.
|
||||||
|
*/
|
||||||
|
const flagged = redFlagStop(s, id, tray, dest, events);
|
||||||
|
if (flagged === 'ask') return 'needsClearance';
|
||||||
|
if (flagged === 'held') return 'held';
|
||||||
|
|
||||||
|
if (yardOfficeQuestion(s, id, tray, dest.seat, events) === 'ask') return 'needsClearance';
|
||||||
|
}
|
||||||
|
|
||||||
node.transits = node.transits.filter((t) => t.tray !== id);
|
node.transits = node.transits.filter((t) => t.tray !== id);
|
||||||
// Red Flags protect a train while it is stopped here; once it rolls, the flags come in.
|
|
||||||
if (node.redFlagged) node.redFlagged = node.redFlagged.filter((t) => t !== id);
|
|
||||||
|
|
||||||
if (!dest) return 'held';
|
if (!dest) return 'held';
|
||||||
|
|
||||||
@@ -966,10 +1025,10 @@ function evaluateClearance(
|
|||||||
): 'clear' | 'blocked' | 'ask' {
|
): 'clear' | 'blocked' | 'ask' {
|
||||||
// A ruling already given for this train is consumed here — this is what stops the driver from
|
// A ruling already given for this train is consumed here — this is what stops the driver from
|
||||||
// re-asking the same question every time it re-evaluates the train.
|
// re-asking the same question every time it re-evaluates the train.
|
||||||
const ruling = s.clock.clearanceRuling;
|
const answer = s.clock.decisionAnswer;
|
||||||
if (ruling && ruling.train === id) {
|
if (answer && answer.kind === 'clearance' && answer.train === id) {
|
||||||
s.clock.clearanceRuling = null;
|
s.clock.decisionAnswer = null;
|
||||||
return ruling.allow ? 'clear' : 'blocked';
|
return answer.allow ? 'clear' : 'blocked';
|
||||||
}
|
}
|
||||||
|
|
||||||
const node = s.division.nodes[targetIndex];
|
const node = s.division.nodes[targetIndex];
|
||||||
@@ -1037,10 +1096,10 @@ function evaluateClearance(
|
|||||||
// from moving". Flagging is per-train rather than per-card, so it protects one specific train
|
// from moving". Flagging is per-train rather than per-card, so it protects one specific train
|
||||||
// where ABS Signals protects everything on the card.
|
// where ABS Signals protects everything on the card.
|
||||||
//
|
//
|
||||||
// Both of these read the card the OTHER train is standing on rather than the card being
|
// Red Flags used to protect a stopped train here as well. Gitea#19 replaced that rule outright
|
||||||
// entered. They were the same card while this only looked one card ahead; across a Subdivision
|
// (Jesse, 2026-08-29): a flag is now planted on a district's Limits and holds trains coming from
|
||||||
// they are not, and the protection belongs where the train it protects actually is.
|
// one direction, so it never applies out on the Mainline. ABS Signals is what protects a train
|
||||||
if (onNode?.kind === 'mainline' && (onNode.redFlagged ?? []).includes(other)) return 'blocked';
|
// standing on a Mainline card now, and it always did the job better.
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ABS Signals — "this is played on a mainline card to prevent collisions. If a collision would
|
* ABS Signals — "this is played on a mainline card to prevent collisions. If a collision would
|
||||||
@@ -1065,13 +1124,211 @@ function evaluateClearance(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Same direction — the Superintendent must rule (§8.1, fourth condition).
|
// Same direction — the Superintendent must rule (§8.1, fourth condition).
|
||||||
s.clock.pendingDecision = { train: id, occupiedBy: other };
|
s.clock.pendingDecision = { kind: 'clearance', train: id, occupiedBy: other };
|
||||||
events.push({ type: 'clearanceRequested', trainId: id, occupiedBy: other });
|
events.push({ type: 'clearanceRequested', trainId: id, occupiedBy: other });
|
||||||
return 'ask';
|
return 'ask';
|
||||||
}
|
}
|
||||||
return 'clear';
|
return 'clear';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CAN THIS TRAIN REACH THE YARD OFFICE, AND IS THE LEAD CLEAR? (Gitea#5)
|
||||||
|
*
|
||||||
|
* Three answers, because Jesse's ruling (2026-08-29) splits two failures his issue describes
|
||||||
|
* separately: "if the Yard Office is not accessible in one move, you should not get the option"
|
||||||
|
* and "cars on the tracks you use to get in result in a crash".
|
||||||
|
*
|
||||||
|
* - `clear` — a route exists and nothing is standing on it. Offer it; taking it is safe.
|
||||||
|
* - `fouled` — a route exists and there are cars on it. Offer it; taking it collides.
|
||||||
|
* - `none` — no route in one move. Do not offer it, and say why in the history.
|
||||||
|
*
|
||||||
|
* WALKED WITH THE ENGINE'S OWN MOVE RULES rather than a bespoke adjacency test. `exploreMoves`
|
||||||
|
* already means exactly what the card's "in one move" means — any distance without changing
|
||||||
|
* direction, finishing on Operational Rail (§2.4, §A.1) — so using it is what makes the code and
|
||||||
|
* the card agree, which was the whole complaint.
|
||||||
|
*
|
||||||
|
* THE FOULING SIGNAL IS `couples`. The walk does not treat standing cars as obstructions: it
|
||||||
|
* COUPLES them, because that is what a switching move does (§A.4). An arriving train is not
|
||||||
|
* switching, so anything it would have coupled is instead something it is about to hit — the same
|
||||||
|
* reading §8.3 already applies to the Running Track.
|
||||||
|
*
|
||||||
|
* The walk starts at the Office square, where a standard arrival puts the train, and leaves by the
|
||||||
|
* way the train is already facing. Reversing is a separate Move (§A.5), so a Yard Office that can
|
||||||
|
* only be reached by backing up is correctly "not in one move".
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* The flag comes down as it stops the train — one card, one train (Gitea#19).
|
||||||
|
*
|
||||||
|
* MUTATES RATHER THAN EMITTING A REDUCED EVENT, because this is the phase driver: `advance.ts`
|
||||||
|
* changes state directly and then describes what it did, and roughly a third of the event types are
|
||||||
|
* never reduced at all (`README.md`, and `tally.ts` on the same asymmetry). A `redFlagSpent`
|
||||||
|
* reducer case looked right and never fired — the flag stayed up and held every train that came.
|
||||||
|
*/
|
||||||
|
function spendFlag(
|
||||||
|
office: Extract<DivisionNode, { kind: 'office' }>,
|
||||||
|
tray: CrewTray,
|
||||||
|
side: Direction,
|
||||||
|
events: GameEvent[],
|
||||||
|
): 'held' {
|
||||||
|
delete office.redFlag;
|
||||||
|
events.push({ type: 'redFlagSpent', seat: office.seat, side, trainNumber: tray.trainNumber ?? 0 });
|
||||||
|
events.push({
|
||||||
|
type: 'trainHeld',
|
||||||
|
trainNumber: tray.trainNumber ?? 0,
|
||||||
|
reason: 'Red Flags — held short of the Limits',
|
||||||
|
});
|
||||||
|
return 'held';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* §Q, RED FLAGS (Gitea#19) — does a flag stop this train, and should its owner be offered one?
|
||||||
|
*
|
||||||
|
* Two jobs, because they are the same moment seen twice: a flag already planted stops the train
|
||||||
|
* outright, and a train about to run into trouble is the cue to offer a flag to somebody holding
|
||||||
|
* the card. "You can play the card normally or out of phase, but only if you need it."
|
||||||
|
*
|
||||||
|
* - `held` — a flag was up on the side this train is coming from. It loses this Mainline
|
||||||
|
* Phase and the flag comes down with it: one card, one train (Jesse, 2026-08-29).
|
||||||
|
* - `ask` — entering would collide and the district's owner holds a Red Flags card.
|
||||||
|
* - `proceed` — neither.
|
||||||
|
*
|
||||||
|
* WHICH SIDE. A train running WEST arrives from the east, so `FLAG EAST` is what holds it — which
|
||||||
|
* is the example the issue gives, and the reason the flag names a side rather than a heading.
|
||||||
|
*/
|
||||||
|
function redFlagStop(
|
||||||
|
s: GameState,
|
||||||
|
id: TrayId,
|
||||||
|
tray: CrewTray,
|
||||||
|
dest: Extract<DivisionNode, { kind: 'office' }>,
|
||||||
|
events: GameEvent[],
|
||||||
|
): 'held' | 'ask' | 'proceed' {
|
||||||
|
const from: Direction = tray.direction === 'east' ? 'west' : 'east';
|
||||||
|
|
||||||
|
// The answer to a prompt already put. Consumed here so the driver cannot ask twice.
|
||||||
|
const answer = s.clock.decisionAnswer;
|
||||||
|
if (answer && answer.kind === 'redFlag' && answer.train === id) {
|
||||||
|
s.clock.decisionAnswer = null;
|
||||||
|
if (!answer.flag) return 'proceed';
|
||||||
|
// The card was spent planting the flag; it stops this train and comes down again at once.
|
||||||
|
return spendFlag(dest, tray, from, events);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dest.redFlag === from) return spendFlag(dest, tray, from, events);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "In actual cases of danger… if there is a train or cars on the track and there will be a
|
||||||
|
* collision, then you break in with a dialog." The two ways an arrival collides are §8.3's own:
|
||||||
|
* no free A/D track, and cars fouling the Running Track. Asked only of a player who can actually
|
||||||
|
* answer — offering a flag to somebody holding no card is a prompt with one button.
|
||||||
|
*/
|
||||||
|
const owner = playerAtSeat(s, dest.seat);
|
||||||
|
const holdsFlag = (s.decks.hands.get(owner) ?? []).some((cid) => {
|
||||||
|
const c = s.cards.get(cid);
|
||||||
|
return c?.kind.kind === 'maneuver' && c.kind.key === 'redFlags';
|
||||||
|
});
|
||||||
|
if (!holdsFlag) return 'proceed';
|
||||||
|
if (!arrivalWouldCollide(s, id, tray, dest.seat)) return 'proceed';
|
||||||
|
|
||||||
|
s.clock.pendingDecision = { kind: 'redFlag', train: id, seat: dest.seat, from };
|
||||||
|
return 'ask';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Would this arrival collide? §8.3's two triggers, asked before the train commits.
|
||||||
|
*
|
||||||
|
* Deliberately a READ of the same conditions `arriveAtOffice` enforces rather than a second rule:
|
||||||
|
* if these two ever diverge, the prompt offers a flag against a collision that will not happen, or
|
||||||
|
* stays silent before one that will.
|
||||||
|
*/
|
||||||
|
function arrivalWouldCollide(s: GameState, id: TrayId, tray: CrewTray, seat: SeatIndex): boolean {
|
||||||
|
const area = areaAtSeat(s, seat);
|
||||||
|
const hasInterlocking = [...area.grid.values()].some((c) => c.enhancements.includes('interlocking'));
|
||||||
|
const full = area.adOccupancy.length >= officeProfile(area.tier).adTracks;
|
||||||
|
// Interlocking turns a full Office into a hold rather than a collision, so it is not danger.
|
||||||
|
if (full && !hasInterlocking) return true;
|
||||||
|
|
||||||
|
// A coach may legally stand at the Office while its engine switches (§A.4's carve-out), so it is
|
||||||
|
// not a hazard to the next arrival. Anything else on the Running Track is.
|
||||||
|
const officeCard = area.grid.get(coordKey(area.officeCoord));
|
||||||
|
return officeCard !== undefined && officeCard.standing.some((c) => c.type !== 'coach');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* §11 (Gitea#5) — should the district's owner be asked about the Yard Office, and is there
|
||||||
|
* anything to ask about?
|
||||||
|
*
|
||||||
|
* Returns `ask` only when the offer is real: a coachless train, a Yard Office card in the district,
|
||||||
|
* and a route to it in one move. Everything else is `proceed`, which means the ordinary arrival.
|
||||||
|
*
|
||||||
|
* ALSO THE PLACE THE HISTORY LEARNS WHY NOT. Jesse, 2026-08-29: "make sure this is logged in
|
||||||
|
* history — why can't move so user knows why they can't get to yard." A qualifying train that is
|
||||||
|
* simply never offered the choice looks exactly like the feature being broken, which is how the
|
||||||
|
* missing reachability check went unnoticed for so long.
|
||||||
|
*/
|
||||||
|
function yardOfficeQuestion(
|
||||||
|
s: GameState,
|
||||||
|
id: TrayId,
|
||||||
|
tray: CrewTray,
|
||||||
|
seat: SeatIndex,
|
||||||
|
events: GameEvent[],
|
||||||
|
): 'ask' | 'proceed' {
|
||||||
|
// Already answered: `arriveAtOffice` consumes it. Asking again would loop the phase for ever.
|
||||||
|
const answer = s.clock.decisionAnswer;
|
||||||
|
if (answer && answer.kind === 'yardOffice' && answer.train === id) return 'proceed';
|
||||||
|
|
||||||
|
if (tray.consist.some((c) => c.type === 'coach')) return 'proceed';
|
||||||
|
const area = areaAtSeat(s, seat);
|
||||||
|
if (![...area.grid.values()].some((c) => c.enhancements.includes('yardOffice'))) return 'proceed';
|
||||||
|
|
||||||
|
const route = yardOfficeRoute(s, seat, id, tray);
|
||||||
|
if (route.kind === 'none') {
|
||||||
|
events.push({
|
||||||
|
type: 'trainDiverted',
|
||||||
|
trainNumber: tray.trainNumber ?? 0,
|
||||||
|
to: 'the Office',
|
||||||
|
reason: `the Yard Office could not be offered — ${route.why}`,
|
||||||
|
});
|
||||||
|
return 'proceed';
|
||||||
|
}
|
||||||
|
|
||||||
|
s.clock.pendingDecision = { kind: 'yardOffice', train: id, seat };
|
||||||
|
return 'ask';
|
||||||
|
}
|
||||||
|
|
||||||
|
type YardOfficeRoute =
|
||||||
|
| { kind: 'clear' | 'fouled'; coord: GridCoord }
|
||||||
|
| { kind: 'none'; why: string };
|
||||||
|
|
||||||
|
function yardOfficeRoute(s: GameState, seat: SeatIndex, id: TrayId, tray: CrewTray): YardOfficeRoute {
|
||||||
|
const area = areaAtSeat(s, seat);
|
||||||
|
const target = [...area.grid.entries()].find(([, card]) => card.enhancements.includes('yardOffice'));
|
||||||
|
if (!target) return { kind: 'none', why: 'there is no Yard Office in this district' };
|
||||||
|
const [key] = target;
|
||||||
|
const [row, col] = key.split(',').map(Number);
|
||||||
|
const coord = { row: row!, col: col! };
|
||||||
|
|
||||||
|
const player = playerAtSeat(s, seat);
|
||||||
|
const facing = railFacingOf(tray);
|
||||||
|
const found = reachableDestinations(
|
||||||
|
{
|
||||||
|
area,
|
||||||
|
occupancy: occupancyFor(s, player, id),
|
||||||
|
consistSize: tray.consist.length,
|
||||||
|
self: id,
|
||||||
|
},
|
||||||
|
area.officeCoord,
|
||||||
|
facing,
|
||||||
|
).find((d) => d.coord.row === coord.row && d.coord.col === coord.col);
|
||||||
|
|
||||||
|
if (!found) {
|
||||||
|
return {
|
||||||
|
kind: 'none',
|
||||||
|
why: 'it cannot be reached from the Office in one move, running the way this train is facing',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { kind: found.couples.length > 0 ? 'fouled' : 'clear', coord };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* §8.3 — arriving at an Office. Collisions here are AUTOMATIC (Gap 2a): if the trigger holds,
|
* §8.3 — arriving at an Office. Collisions here are AUTOMATIC (Gap 2a): if the trigger holds,
|
||||||
* the collision happens, with no die roll and no judgment.
|
* the collision happens, with no die roll and no judgment.
|
||||||
@@ -1088,22 +1345,51 @@ function arriveAtOffice(
|
|||||||
const hasEnhancement = (key: string): boolean =>
|
const hasEnhancement = (key: string): boolean =>
|
||||||
[...area.grid.values()].some((c) => c.enhancements.includes(key));
|
[...area.grid.values()].some((c) => c.enhancements.includes(key));
|
||||||
|
|
||||||
// Yard Office — "an inbound train with NO COACHES that can make a single move to the yard office
|
/**
|
||||||
// track may arrive there, not at the Train Order Office". It sidesteps the A/D track entirely.
|
* §11, THE YARD OFFICE (Gitea#5) — offered, not imposed.
|
||||||
const noCoaches = !tray.consist.some((c) => c.type === 'coach');
|
*
|
||||||
if (noCoaches && hasEnhancement('yardOffice')) {
|
* "Trains that are only freight (cabooses ok, no coaches allowed) that arrive in a player's area
|
||||||
for (const [key, card] of area.grid) {
|
* who has the yard office card get an extra ability. On the turn (mainline phase) that the train
|
||||||
if (!card.enhancements.includes('yardOffice')) continue;
|
* arrives the game will offer that player the option to have that train go directly to the yard
|
||||||
const [row, col] = key.split(',').map(Number);
|
* office card instead of the standard office. They can of course still choose to have the train
|
||||||
tray.position = { at: 'grid', seat, coord: { row: row!, col: col! } };
|
* go to the standard office."
|
||||||
|
*
|
||||||
|
* WHAT THIS USED TO DO, and why all three of the rule's conditions were missing: a qualifying
|
||||||
|
* train was TELEPORTED onto the Yard Office card. The player was never asked, no route was ever
|
||||||
|
* computed — so the card's own printed text, "that can reach the yard office in one move", was
|
||||||
|
* unenforced — and because nothing was walked, nothing was ever met on the way in.
|
||||||
|
*
|
||||||
|
* The answer comes back through `pendingDecision`, so this returns `needsClearance` and is
|
||||||
|
* re-entered once the player has answered. `yardOfficeOffer` below is where the route is walked.
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* The answer to the offer `yardOfficeQuestion` put before the train left the Mainline card.
|
||||||
|
* Absent — because the train has no Yard Office, or no route to it, or carries coaches — this
|
||||||
|
* falls straight through to the ordinary arrival below.
|
||||||
|
*/
|
||||||
|
const answer = s.clock.decisionAnswer;
|
||||||
|
if (answer && answer.kind === 'yardOffice' && answer.train === id) {
|
||||||
|
s.clock.decisionAnswer = null;
|
||||||
|
const route = answer.take ? yardOfficeRoute(s, seat, id, tray) : { kind: 'none' as const };
|
||||||
|
if (route.kind !== 'none') {
|
||||||
|
tray.position = { at: 'grid', seat, coord: route.coord };
|
||||||
events.push({
|
events.push({
|
||||||
type: 'trainDiverted',
|
type: 'trainDiverted',
|
||||||
trainNumber: tray.trainNumber ?? 0,
|
trainNumber: tray.trainNumber ?? 0,
|
||||||
to: 'the Yard Office',
|
to: 'the Yard Office',
|
||||||
reason: 'no coaches, so it need not occupy the Train Order Office',
|
reason: 'no coaches, so it need not occupy the Train Order Office',
|
||||||
});
|
});
|
||||||
|
/**
|
||||||
|
* Cars on the lead are a COLLISION, not a coupling — the same §8.3 rule that governs the
|
||||||
|
* Running Track, and the third of the three things this implementation was missing. An
|
||||||
|
* arriving train is at speed and is not expecting them (§A.4).
|
||||||
|
*/
|
||||||
|
if (route.kind === 'fouled') {
|
||||||
|
collide(s, playerAtSeat(s, seat), [id], events, 'cars fouling the lead into the Yard Office', 'the Yard Office');
|
||||||
|
}
|
||||||
return 'moved';
|
return 'moved';
|
||||||
}
|
}
|
||||||
|
// Declined: fall through to the standard Office, with its own capacity and collision rules.
|
||||||
}
|
}
|
||||||
|
|
||||||
// Gap 2d — no room at the station is a collision, and it is the local player's fault (§10).
|
// Gap 2d — no room at the station is a collision, and it is the local player's fault (§10).
|
||||||
@@ -1216,7 +1502,6 @@ function collide(
|
|||||||
if (n.kind !== 'mainline') continue;
|
if (n.kind !== 'mainline') continue;
|
||||||
n.transits = n.transits.filter((t) => t.tray !== id);
|
n.transits = n.transits.filter((t) => t.tray !== id);
|
||||||
if (n.holding) n.holding = n.holding.filter((t) => t !== id);
|
if (n.holding) n.holding = n.holding.filter((t) => t !== id);
|
||||||
if (n.redFlagged) n.redFlagged = n.redFlagged.filter((t) => t !== id);
|
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* AND OFF THE A/D TRACK, for exactly the same reason as the transit above.
|
* AND OFF THE A/D TRACK, for exactly the same reason as the transit above.
|
||||||
@@ -1351,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 =
|
||||||
|
|||||||
+153
-26
@@ -58,6 +58,8 @@ import {
|
|||||||
carsOn,
|
carsOn,
|
||||||
coordKey,
|
coordKey,
|
||||||
cutTowards,
|
cutTowards,
|
||||||
|
decisionActor,
|
||||||
|
officeNodeFor,
|
||||||
isOperationalRail,
|
isOperationalRail,
|
||||||
playerAtSeat,
|
playerAtSeat,
|
||||||
pooled,
|
pooled,
|
||||||
@@ -124,7 +126,12 @@ function trayCoord(s: GameState, trayId: TrayId): GridCoord | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** A tray sitting on the Office card occupies an A/D track (§2.1). */
|
/** A tray sitting on the Office card occupies an A/D track (§2.1). */
|
||||||
function occupancyFor(s: GameState, player: PlayerIndex, self: TrayId): Occupancy {
|
/**
|
||||||
|
* Exported for `advance.ts`'s Yard Office walk (Gitea#5), which has to ask the SAME occupancy
|
||||||
|
* question a switching move asks — a second copy would be free to drift into a different answer
|
||||||
|
* about which cards are free.
|
||||||
|
*/
|
||||||
|
export function occupancyFor(s: GameState, player: PlayerIndex, self: TrayId): Occupancy {
|
||||||
const area = areaOf(s, player);
|
const area = areaOf(s, player);
|
||||||
return {
|
return {
|
||||||
trayAt: (c) => {
|
trayAt: (c) => {
|
||||||
@@ -792,11 +799,42 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
|
|||||||
// The clearance ruling is the one intent that arrives out of turn order: it interrupts the
|
// The clearance ruling is the one intent that arrives out of turn order: it interrupts the
|
||||||
// automatic Mainline Phase and goes to the Superintendent (§8.1, fourth condition).
|
// automatic Mainline Phase and goes to the Superintendent (§8.1, fourth condition).
|
||||||
if (i.type === 'mainline.clearance') {
|
if (i.type === 'mainline.clearance') {
|
||||||
if (s.clock.pendingDecision === null) return 'NO_PENDING_DECISION';
|
if (s.clock.pendingDecision?.kind !== 'clearance') return 'NO_PENDING_DECISION';
|
||||||
if (s.clock.superintendent !== player) return 'NOT_SUPERINTENDENT';
|
if (s.clock.superintendent !== player) return 'NOT_SUPERINTENDENT';
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* §Q (Gitea#19) — the Red Flag prompt, the third interruption of the Mainline Phase.
|
||||||
|
*
|
||||||
|
* Only ever raised for a player who holds the card, so `flag: true` can always be paid for; the
|
||||||
|
* card is checked again here because `check` is the authority and a hand can change between the
|
||||||
|
* prompt being raised and answered.
|
||||||
|
*/
|
||||||
|
if (i.type === 'mainline.redFlag') {
|
||||||
|
if (s.clock.pendingDecision?.kind !== 'redFlag') return 'NO_RED_FLAG_PROMPT';
|
||||||
|
if (decisionActor(s) !== player) return 'NOT_YOUR_TURN';
|
||||||
|
if (!i.flag) return null;
|
||||||
|
const held = (s.decks.hands.get(player) ?? []).find((id) => {
|
||||||
|
const c = s.cards.get(id);
|
||||||
|
return c?.kind.kind === 'maneuver' && c.kind.key === 'redFlags';
|
||||||
|
});
|
||||||
|
if (!held) return 'NO_SUCH_CARD';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* §11 (Gitea#5) — the Yard Office offer, the second interruption of the Mainline Phase.
|
||||||
|
*
|
||||||
|
* Goes to the district's owner rather than the Superintendent, which is the whole reason
|
||||||
|
* `pendingDecision` became a union. `decisionActor` is the single place that mapping lives.
|
||||||
|
*/
|
||||||
|
if (i.type === 'mainline.yardOffice') {
|
||||||
|
if (s.clock.pendingDecision?.kind !== 'yardOffice') return 'NO_YARD_OFFICE_OFFER';
|
||||||
|
if (decisionActor(s) !== player) return 'NOT_YOUR_TURN';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
if (!isActor(s, player)) return 'NOT_YOUR_TURN';
|
if (!isActor(s, player)) return 'NOT_YOUR_TURN';
|
||||||
|
|
||||||
switch (i.type) {
|
switch (i.type) {
|
||||||
@@ -1006,14 +1044,9 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
|
|||||||
const card = s.cards.get(i.cardId);
|
const card = s.cards.get(i.cardId);
|
||||||
if (!card || !(s.decks.hands.get(player) ?? []).includes(i.cardId)) return 'NO_SUCH_CARD';
|
if (!card || !(s.decks.hands.get(player) ?? []).includes(i.cardId)) return 'NO_SUCH_CARD';
|
||||||
if (card.kind.kind !== 'maneuver' || card.kind.key !== 'redFlags') return 'WRONG_INTENT';
|
if (card.kind.kind !== 'maneuver' || card.kind.key !== 'redFlags') return 'WRONG_INTENT';
|
||||||
const tray = s.trays.get(i.trayId);
|
// §Q (Gitea#19) — a flag goes on your OWN Limits. There is no target train to name and no
|
||||||
if (!tray) return 'NO_SUCH_TRAY';
|
// placement to find: the district is yours, and the only question is which side.
|
||||||
// "A STOPPED train is prevented from being hit" — it protects a train that is standing on a
|
if (officeNodeFor(s, seatOf(s, player))?.redFlag === i.side) return 'ALREADY_FLAGGED';
|
||||||
// Mainline card, which is the only place a rear-ender can happen.
|
|
||||||
if (tray.position.at !== 'mainline') return 'NO_PLACEMENT';
|
|
||||||
const node = s.division.nodes[tray.position.index];
|
|
||||||
if (!node || node.kind !== 'mainline') return 'NO_PLACEMENT';
|
|
||||||
if ((node.redFlagged ?? []).includes(i.trayId)) return 'OPTION_ALREADY_CHOSEN';
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1159,7 +1192,7 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
|
|||||||
// This was not enforced at all: any car could be added in any quantity, so Train 9 "Heavy
|
// This was not enforced at all: any car could be added in any quantity, so Train 9 "Heavy
|
||||||
// Freight" — a card calling for 3 freight AND a caboose — was made up with four hoppers and
|
// Freight" — a card calling for 3 freight AND a caboose — was made up with four hoppers and
|
||||||
// no caboose. Fewer is allowed; more, or of the wrong category, is not.
|
// no caboose. Fewer is allowed; more, or of the wrong category, is not.
|
||||||
return acceptsCar(tray, i.carType) ? null : 'NO_SUITABLE_CAR';
|
return acceptsCar(tray, i.carType, i.loaded, s.yards.divisionYard) ? null : 'NO_SUITABLE_CAR';
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'newTrain.secondSection': {
|
case 'newTrain.secondSection': {
|
||||||
@@ -1571,6 +1604,12 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
|
|||||||
: [vote];
|
: [vote];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case 'mainline.yardOffice': {
|
||||||
|
const pending = s.clock.pendingDecision;
|
||||||
|
const trainId = pending?.kind === 'yardOffice' ? pending.train : '';
|
||||||
|
return [{ type: 'yardOfficeRuled', player, trainId, take: i.take }];
|
||||||
|
}
|
||||||
|
|
||||||
case 'localOps.choose':
|
case 'localOps.choose':
|
||||||
return [{ type: 'localOpsOptionChosen', player, option: i.option }];
|
return [{ type: 'localOpsOptionChosen', player, option: i.option }];
|
||||||
|
|
||||||
@@ -1804,10 +1843,25 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'maneuver.redFlags': {
|
case 'maneuver.redFlags':
|
||||||
const tray = s.trays.get(i.trayId)!;
|
return [{ type: 'redFlagsSet', player, cardId: i.cardId, seat: seatOf(s, player), side: i.side }];
|
||||||
const index = tray.position.at === 'mainline' ? tray.position.index : -1;
|
|
||||||
return [{ type: 'redFlagsSet', player, cardId: i.cardId, trayId: i.trayId, node: index }];
|
case 'mainline.redFlag': {
|
||||||
|
const pending = s.clock.pendingDecision;
|
||||||
|
const trainId = pending?.kind === 'redFlag' ? pending.train : '';
|
||||||
|
const seat = pending?.kind === 'redFlag' ? pending.seat : 0;
|
||||||
|
const side = pending?.kind === 'redFlag' ? pending.from : 'east';
|
||||||
|
if (!i.flag) return [{ type: 'redFlagRuled', player, trainId, flag: false }];
|
||||||
|
const cardId =
|
||||||
|
i.cardId ??
|
||||||
|
(s.decks.hands.get(player) ?? []).find((id) => {
|
||||||
|
const c = s.cards.get(id);
|
||||||
|
return c?.kind.kind === 'maneuver' && c.kind.key === 'redFlags';
|
||||||
|
})!;
|
||||||
|
return [
|
||||||
|
{ type: 'redFlagsSet', player, cardId, seat, side },
|
||||||
|
{ type: 'redFlagRuled', player, trainId, flag: true },
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'maneuver.flyingSwitch': {
|
case 'maneuver.flyingSwitch': {
|
||||||
@@ -2005,6 +2059,13 @@ export function reduce(s: GameState, e: GameEvent): void {
|
|||||||
s.status = 'finished';
|
s.status = 'finished';
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
// §11 (Gitea#5) — the same shape as `clearanceGiven`: clear the question, record the answer for
|
||||||
|
// the arriving train to consume, or the driver asks again for ever.
|
||||||
|
case 'yardOfficeRuled':
|
||||||
|
s.clock.pendingDecision = null;
|
||||||
|
s.clock.decisionAnswer = { kind: 'yardOffice', train: e.trainId, take: e.take };
|
||||||
|
break;
|
||||||
|
|
||||||
case 'localOpsOptionChosen':
|
case 'localOpsOptionChosen':
|
||||||
turnOf(s, e.player).option = e.option;
|
turnOf(s, e.player).option = e.option;
|
||||||
break;
|
break;
|
||||||
@@ -2229,14 +2290,23 @@ export function reduce(s: GameState, e: GameEvent): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'redFlagsSet': {
|
case 'redFlagsSet': {
|
||||||
const node = s.division.nodes[e.node];
|
const node = officeNodeFor(s, e.seat);
|
||||||
if (node?.kind === 'mainline') {
|
if (node) node.redFlag = e.side;
|
||||||
node.redFlagged = [...(node.redFlagged ?? []), e.trayId];
|
|
||||||
}
|
|
||||||
spendCard(s, e.player, e.cardId);
|
spendCard(s, e.player, e.cardId);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* §Q (Gitea#19) — `redFlagSpent` is NOT reduced, deliberately. It is emitted only by the phase
|
||||||
|
* driver, which mutates state itself and then describes it (`advance.ts`'s `spendFlag`), so a
|
||||||
|
* case here would be dead code that reads as the live one.
|
||||||
|
*/
|
||||||
|
|
||||||
|
case 'redFlagRuled':
|
||||||
|
s.clock.pendingDecision = null;
|
||||||
|
s.clock.decisionAnswer = { kind: 'redFlag', train: e.trainId, flag: e.flag };
|
||||||
|
break;
|
||||||
|
|
||||||
case 'flyingSwitch': {
|
case 'flyingSwitch': {
|
||||||
const tray = s.trays.get(e.trayId);
|
const tray = s.trays.get(e.trayId);
|
||||||
const area = areaOf(s, e.player);
|
const area = areaOf(s, e.player);
|
||||||
@@ -2593,7 +2663,7 @@ export function reduce(s: GameState, e: GameEvent): void {
|
|||||||
case 'clearanceGiven':
|
case 'clearanceGiven':
|
||||||
s.clock.pendingDecision = null;
|
s.clock.pendingDecision = null;
|
||||||
// Recorded for the asking train to consume; otherwise the driver asks again forever.
|
// Recorded for the asking train to consume; otherwise the driver asks again forever.
|
||||||
s.clock.clearanceRuling = { train: e.trainId, allow: e.allow };
|
s.clock.decisionAnswer = { kind: 'clearance', train: e.trainId, allow: e.allow };
|
||||||
break;
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
@@ -2996,7 +3066,20 @@ function extendLimitsIfNeeded(area: OfficeArea, placed: GridCoord): void {
|
|||||||
* this. A second copy stalled the game outright: the phase believed a car could be added while
|
* this. A second copy stalled the game outright: the phase believed a car could be added while
|
||||||
* `check` rejected every option, so the Stage never completed.
|
* `check` rejected every option, so the Stage never completed.
|
||||||
*/
|
*/
|
||||||
export function acceptsCar(tray: CrewTray, carType: CarType): boolean {
|
export function acceptsCar(
|
||||||
|
tray: CrewTray,
|
||||||
|
carType: CarType,
|
||||||
|
/**
|
||||||
|
* Whether the car being offered is loaded, and what the Division Yard still holds.
|
||||||
|
*
|
||||||
|
* Both optional so that a caller asking the SHAPE question — "does this card take a car of this
|
||||||
|
* category at all?" — need not answer the loading question. `trainNeedingCars` asks the shape
|
||||||
|
* question of every car in the yard; `check` asks the full one about a specific car a player has
|
||||||
|
* named. Omitting them skips the loading rules rather than guessing at them.
|
||||||
|
*/
|
||||||
|
loaded?: boolean,
|
||||||
|
yard?: readonly RollingStock[],
|
||||||
|
): boolean {
|
||||||
const profile = trainProfile(tray.trainNumber ?? 0, tray.trainIsExtra);
|
const profile = trainProfile(tray.trainNumber ?? 0, tray.trainIsExtra);
|
||||||
if (!profile) return true;
|
if (!profile) return true;
|
||||||
|
|
||||||
@@ -3019,6 +3102,40 @@ export function acceptsCar(tray: CrewTray, carType: CarType): boolean {
|
|||||||
const types = profile.consist.freightTypes;
|
const types = profile.consist.freightTypes;
|
||||||
if (adding === 'freight' && types && !types.includes(carType)) return false;
|
if (adding === 'freight' && types && !types.includes(carType)) return false;
|
||||||
|
|
||||||
|
if (loaded === undefined) return true;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* X13 APPLESEED — "may drop MTs but not pick up anything", and its consist prints EMPTIES ONLY.
|
||||||
|
*
|
||||||
|
* `emptiesOnly` was declared on the card, RENDERED to the player as "(empties only)" by both
|
||||||
|
* `web/game.ts` and `sim/view.ts`, and enforced by nothing: the Appleseed could be made up with
|
||||||
|
* loaded cars while its own card said it could not. Found while building Gitea#13, which is the
|
||||||
|
* same rule pointing the other way, and fixed with it rather than left as the odd one out.
|
||||||
|
*
|
||||||
|
* A caboose is exempt. Every caboose in `ROLLING_STOCK_SUPPLY` is `loaded: true` — there is no
|
||||||
|
* such thing as an empty one — so applying this to the caboose would bar the Appleseed from the
|
||||||
|
* caboose its own consist calls for.
|
||||||
|
*/
|
||||||
|
if (profile.consist.emptiesOnly && loaded && adding !== 'caboose') return false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MUST RUN LOADED (Gitea#13) — a preference order, not a flat requirement.
|
||||||
|
*
|
||||||
|
* "If not loaded, then empty, and if none available, run without." So an EMPTY is refused only
|
||||||
|
* while the yard can still supply a loaded car this train would accept; once it cannot, the empty
|
||||||
|
* becomes legal and the train may also simply depart short. Asked of the yard rather than
|
||||||
|
* remembered on the tray, because the yard is what the rule is about and it changes under the
|
||||||
|
* train as other consists are built.
|
||||||
|
*
|
||||||
|
* The caboose is exempt for the same reason as above.
|
||||||
|
*/
|
||||||
|
if (profile.rules.mustRunLoaded && !loaded && adding !== 'caboose' && yard) {
|
||||||
|
const loadedAvailable = yard.some(
|
||||||
|
(c) => c.loaded && cat(c.type) === adding && acceptsCar(tray, c.type),
|
||||||
|
);
|
||||||
|
if (loadedAvailable) return false;
|
||||||
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3059,11 +3176,21 @@ export function isBeingMadeUp(tray: CrewTray): boolean {
|
|||||||
export function trainNeedingCars(s: GameState): TrayId | null {
|
export function trainNeedingCars(s: GameState): TrayId | null {
|
||||||
for (const [id, tray] of s.trays) {
|
for (const [id, tray] of s.trays) {
|
||||||
if (!isBeingMadeUp(tray)) continue;
|
if (!isBeingMadeUp(tray)) continue;
|
||||||
// Consists are specified by CATEGORY — "Freight (2)" is any two freight cars — so any car in
|
/**
|
||||||
// the yard is potentially suitable unless the card narrows it. Ask the SAME predicate `check`
|
* Consists are specified by CATEGORY — "Freight (2)" is any two freight cars — so any car in
|
||||||
// uses: a separate copy of this test stalled the game, because the phase believed a car could
|
* the yard is potentially suitable unless the card narrows it. Ask the SAME predicate `check`
|
||||||
// be added while `check` rejected every option, so the Stage never ended.
|
* uses: a separate copy of this test stalled the game, because the phase believed a car could
|
||||||
if (s.yards.divisionYard.some((c) => acceptsCar(tray, c.type))) return id;
|
* be added while `check` rejected every option, so the Stage never ended.
|
||||||
|
*
|
||||||
|
* ASKED PER CAR, WITH ITS LOADED STATE, since Gitea#13. The shape question alone is no longer
|
||||||
|
* the same question `check` answers: an `emptiesOnly` train looking at a yard of nothing but
|
||||||
|
* loaded cars, or a `mustRunLoaded` train offered only empties while loaded ones remain, would
|
||||||
|
* both be told a car was available and then refused every one of them — the very stall this
|
||||||
|
* comment was written about.
|
||||||
|
*/
|
||||||
|
if (s.yards.divisionYard.some((c) => acceptsCar(tray, c.type, c.loaded, s.yards.divisionYard))) {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-3
@@ -434,6 +434,19 @@ export type TrainRules = {
|
|||||||
/** X17 Campaign, X18 Circus: a scheduled stop that does something. */
|
/** X17 Campaign, X18 Circus: a scheduled stop that does something. */
|
||||||
stopEarnsPoint?: boolean;
|
stopEarnsPoint?: boolean;
|
||||||
stopThenExpedite?: boolean;
|
stopThenExpedite?: boolean;
|
||||||
|
/**
|
||||||
|
* MUST RUN LOADED (Gitea#13) — X17 Campaign, X18 Circus, X19 Military.
|
||||||
|
*
|
||||||
|
* "I've redefined some of the extra trains that they have to run full boxcars (not just any crazy
|
||||||
|
* stuff) — military trains, circus trains, etc. If not loaded, then empty, and if none available,
|
||||||
|
* run without." So it is a PREFERENCE ORDER enforced at make-up, not a flat requirement: a loaded
|
||||||
|
* car of an acceptable type must be taken while one is in the Division Yard; only once none is
|
||||||
|
* left may an empty be taken; and a train may still depart short (§8.2 already allows fewer cars
|
||||||
|
* than the card lists).
|
||||||
|
*
|
||||||
|
* Distinct from `ConsistSpec.emptiesOnly`, which is the opposite rule for X13 Appleseed.
|
||||||
|
*/
|
||||||
|
mustRunLoaded?: boolean;
|
||||||
/**
|
/**
|
||||||
* `copiesNextScheduled` was here and is DELETED. No train card ever carried it: a Second Section
|
* `copiesNextScheduled` was here and is DELETED. No train card ever carried it: a Second Section
|
||||||
* is a Maneuver card played on a train that is due out, and it has its own intent
|
* is a Maneuver card played on a train that is due out, and it has its own intent
|
||||||
@@ -492,9 +505,9 @@ export const EXTRA_TRAINS: readonly TrainProfile[] = [
|
|||||||
{ number: 14, isExtra: true, name: 'Fruit Growers Express', speed: 'fast', direction: 'playerChoice', consist: { freight: 2, coach: 0, caboose: 1, freightTypes: ['reefer'] }, rules: { expedite: true, note: 'Reefers only. May pick up one extra loaded reefer.' } },
|
{ number: 14, isExtra: true, name: 'Fruit Growers Express', speed: 'fast', direction: 'playerChoice', consist: { freight: 2, coach: 0, caboose: 1, freightTypes: ['reefer'] }, rules: { expedite: true, note: 'Reefers only. May pick up one extra loaded reefer.' } },
|
||||||
{ number: 15, isExtra: true, name: 'Yard Xfer', speed: 'slow', direction: 'playerChoice', consist: { freight: 2, coach: 0, caboose: 1 }, rules: {} },
|
{ number: 15, isExtra: true, name: 'Yard Xfer', speed: 'slow', direction: 'playerChoice', consist: { freight: 2, coach: 0, caboose: 1 }, rules: {} },
|
||||||
{ number: 16, isExtra: true, name: 'Light Engine Move', speed: 'fast', direction: 'playerChoice', consist: { freight: 0, coach: 0, caboose: 0 }, rules: { noSwitching: true, note: 'No cars at all.' } },
|
{ number: 16, isExtra: true, name: 'Light Engine Move', speed: 'fast', direction: 'playerChoice', consist: { freight: 0, coach: 0, caboose: 0 }, rules: { noSwitching: true, note: 'No cars at all.' } },
|
||||||
{ number: 17, isExtra: true, name: 'Campaign Train', speed: 'fast', direction: 'playerChoice', consist: { freight: 0, coach: 1, caboose: 0 }, rules: { noSwitching: true, stopThenExpedite: true, note: 'One turn at station (speeches) then expedite.' } },
|
{ number: 17, isExtra: true, name: 'Campaign Train', speed: 'fast', direction: 'playerChoice', consist: { freight: 0, coach: 1, caboose: 0 }, rules: { noSwitching: true, stopThenExpedite: true, stopEarnsPoint: true, mustRunLoaded: true, note: 'One turn at station (speeches) then expedite. Earns a point per Office Area if the candidate is aboard.' } },
|
||||||
{ number: 18, isExtra: true, name: 'Circus Train', speed: 'slow', direction: 'playerChoice', consist: { freight: 2, coach: 1, caboose: 1 }, rules: { noSwitching: true, stopEarnsPoint: true, note: 'One turn stopped on any track (circus set-up) earns 1 point.' } },
|
{ number: 18, isExtra: true, name: 'Circus Train', speed: 'slow', direction: 'playerChoice', consist: { freight: 2, coach: 1, caboose: 1 }, rules: { noSwitching: true, stopEarnsPoint: true, mustRunLoaded: true, note: 'One turn stopped in an Office Area (circus set-up) earns 1 point, once per Area, if fully loaded.' } },
|
||||||
{ number: 19, isExtra: true, name: 'Military Train', speed: 'slow', direction: 'playerChoice', consist: { freight: 1, coach: 2, caboose: 0 }, rules: { noSwitching: true, noPassengerWork: true, expedite: true } },
|
{ number: 19, isExtra: true, name: 'Military Train', speed: 'slow', direction: 'playerChoice', consist: { freight: 1, coach: 2, caboose: 0 }, rules: { noSwitching: true, noPassengerWork: true, expedite: true, mustRunLoaded: true, note: 'Troops and materiel: runs loaded where the yard can supply it.' } },
|
||||||
{ number: 20, isExtra: true, name: "Director's private car", speed: 'slow', direction: 'playerChoice', consist: { freight: 2, coach: 1, caboose: 0 }, rules: { noPassengerWork: true } },
|
{ number: 20, isExtra: true, name: "Director's private car", speed: 'slow', direction: 'playerChoice', consist: { freight: 2, coach: 1, caboose: 0 }, rules: { noPassengerWork: true } },
|
||||||
{ number: 21, isExtra: true, name: 'Freight Extra', speed: 'slow', direction: 'playerChoice', consist: { freight: 3, coach: 0, caboose: 1 }, rules: {} },
|
{ number: 21, isExtra: true, name: 'Freight Extra', speed: 'slow', direction: 'playerChoice', consist: { freight: 3, coach: 0, caboose: 1 }, rules: {} },
|
||||||
{ number: 22, isExtra: true, name: 'Pee-Dee', speed: 'slow', direction: 'playerChoice', consist: { freight: 0, coach: 0, caboose: 1 }, rules: { pickUpEmptiesOnly: true, note: 'Per-diem train. May only pick up MTs.' } },
|
{ number: 22, isExtra: true, name: 'Pee-Dee', speed: 'slow', direction: 'playerChoice', consist: { freight: 0, coach: 0, caboose: 1 }, rules: { pickUpEmptiesOnly: true, note: 'Per-diem train. May only pick up MTs.' } },
|
||||||
|
|||||||
@@ -87,7 +87,12 @@ export type GameEvent =
|
|||||||
/** `variant` is the chosen orientation (Gap 11); it must be replayable, so it rides the event. */
|
/** `variant` is the chosen orientation (Gap 11); it must be replayable, so it rides the event. */
|
||||||
| { type: 'cardPlayed'; player: PlayerIndex; cardId: CardId; placement?: GridCoord; variant?: number }
|
| { type: 'cardPlayed'; player: PlayerIndex; cardId: CardId; placement?: GridCoord; variant?: number }
|
||||||
| { type: 'mainlineModified'; player: PlayerIndex; cardId: CardId; node: number; key: string; became?: string }
|
| { type: 'mainlineModified'; player: PlayerIndex; cardId: CardId; node: number; key: string; became?: string }
|
||||||
| { type: 'redFlagsSet'; player: PlayerIndex; cardId: CardId; trayId: TrayId; node: number }
|
/** §Q (Gitea#19) — a flag planted on one side of a district's Limits. */
|
||||||
|
| { type: 'redFlagsSet'; player: PlayerIndex; cardId: CardId; seat: SeatIndex; side: Direction }
|
||||||
|
/** §Q (Gitea#19) — the flag stopped a train and came down with it. One card, one train. */
|
||||||
|
| { type: 'redFlagSpent'; seat: SeatIndex; side: Direction; trainNumber: number }
|
||||||
|
/** §Q (Gitea#19) — the district's owner answered the out-of-phase "flag against this train?". */
|
||||||
|
| { type: 'redFlagRuled'; player: PlayerIndex; trainId: TrayId; flag: boolean }
|
||||||
| {
|
| {
|
||||||
type: 'trainsDestroyed';
|
type: 'trainsDestroyed';
|
||||||
player: PlayerIndex;
|
player: PlayerIndex;
|
||||||
@@ -212,6 +217,8 @@ export type GameEvent =
|
|||||||
* ending that COULD have been played past and was not is a decision the table made, and the log
|
* ending that COULD have been played past and was not is a decision the table made, and the log
|
||||||
* should say so rather than simply stopping.
|
* should say so rather than simply stopping.
|
||||||
*/
|
*/
|
||||||
| { type: 'playConcluded'; declinedBy: PlayerIndex };
|
| { type: 'playConcluded'; declinedBy: PlayerIndex }
|
||||||
|
/** §11 (Gitea#5) — the district's owner answered the Yard Office offer. */
|
||||||
|
| { type: 'yardOfficeRuled'; player: PlayerIndex; trainId: TrayId; take: boolean };
|
||||||
|
|
||||||
export type EventType = GameEvent['type'];
|
export type EventType = GameEvent['type'];
|
||||||
|
|||||||
+34
-5
@@ -120,10 +120,24 @@ export type Intent =
|
|||||||
*/
|
*/
|
||||||
| { type: 'mainline.modify'; cardId: CardId; node: number }
|
| { type: 'mainline.modify'; cardId: CardId; node: number }
|
||||||
/**
|
/**
|
||||||
* Red Flags — protect a stopped train. The flagged train cannot be hit; an approaching train is
|
* §Q, RED FLAGS (Gitea#19) — plant a flag on one side of your own district.
|
||||||
* held instead of colliding.
|
*
|
||||||
|
* "If played, asked FLAG EAST or FLAG WEST. That stops all trains from entering your limits from
|
||||||
|
* that direction (i.e. Flag East holds westbound trains). You can do this if you see a problem or
|
||||||
|
* wish to complete switching."
|
||||||
|
*
|
||||||
|
* `side` names the side of the district the flag goes on, so a train arriving from that side is
|
||||||
|
* held. It REPLACES the old rule, which was played on a stopped train out on the Mainline and
|
||||||
|
* protected it from a rear-ender: measured at 4,212 offers and 4 plays across 600 games, a
|
||||||
|
* mechanic nobody used. ABS Signals already protects a train standing on a Mainline card.
|
||||||
*/
|
*/
|
||||||
| { type: 'maneuver.redFlags'; cardId: CardId; trayId: TrayId }
|
| { type: 'maneuver.redFlags'; cardId: CardId; side: Direction }
|
||||||
|
/**
|
||||||
|
* The same card, played OUT OF PHASE at the moment of danger (Gitea#19) — "COLLISION RISK! FLAG
|
||||||
|
* AGAINST T2?". Answers a pending `redFlag` decision; `flag: false` declines and lets the
|
||||||
|
* collision happen. The side is not asked for: the train is already coming from one.
|
||||||
|
*/
|
||||||
|
| { type: 'mainline.redFlag'; flag: boolean; cardId?: CardId }
|
||||||
/**
|
/**
|
||||||
* Flying Switch — cut cars off behind the engine and roll them into an adjacent industry, without
|
* Flying Switch — cut cars off behind the engine and roll them into an adjacent industry, without
|
||||||
* the engine entering it.
|
* the engine entering it.
|
||||||
@@ -168,7 +182,16 @@ export type Intent =
|
|||||||
* resumed server would refuse the save with `NO_ACTOR`. The server checks this against the seat
|
* resumed server would refuse the save with `NO_ACTOR`. The server checks this against the seat
|
||||||
* it authenticated (`NOT_YOUR_TURN`), so it is a record, never a claim.
|
* it authenticated (`NOT_YOUR_TURN`), so it is a record, never a claim.
|
||||||
*/
|
*/
|
||||||
| { type: 'game.extend'; player: PlayerIndex; agree: boolean };
|
| { type: 'game.extend'; player: PlayerIndex; agree: boolean }
|
||||||
|
/**
|
||||||
|
* §11 (Gitea#5) — take the Yard Office, or the standard Office.
|
||||||
|
*
|
||||||
|
* Interrupts the Mainline Phase like `mainline.clearance`, and like it goes to one named player:
|
||||||
|
* whoever sits in the district the train is arriving at. Offered only when a route exists, so
|
||||||
|
* `take: true` always has somewhere to go — though it may still meet cars on the lead and crash,
|
||||||
|
* which is the point of the rule.
|
||||||
|
*/
|
||||||
|
| { type: 'mainline.yardOffice'; take: boolean };
|
||||||
|
|
||||||
export type IntentType = Intent['type'];
|
export type IntentType = Intent['type'];
|
||||||
|
|
||||||
@@ -301,7 +324,13 @@ export type RejectionCode =
|
|||||||
/** §3.3 (Gitea#11) — `game.extend` when the game is not waiting on an extension vote. */
|
/** §3.3 (Gitea#11) — `game.extend` when the game is not waiting on an extension vote. */
|
||||||
| 'NOT_AWAITING_EXTENSION'
|
| 'NOT_AWAITING_EXTENSION'
|
||||||
/** §3.3 (Gitea#11) — this seat has already voted on this extension. */
|
/** §3.3 (Gitea#11) — this seat has already voted on this extension. */
|
||||||
| 'ALREADY_VOTED';
|
| 'ALREADY_VOTED'
|
||||||
|
/** §11 (Gitea#5) — answering a Yard Office offer that is not open. */
|
||||||
|
| 'NO_YARD_OFFICE_OFFER'
|
||||||
|
/** §Q (Gitea#19) — answering a Red Flag prompt that is not open. */
|
||||||
|
| 'NO_RED_FLAG_PROMPT'
|
||||||
|
/** §Q (Gitea#19) — this district already has a flag on that side. */
|
||||||
|
| 'ALREADY_FLAGGED';
|
||||||
|
|
||||||
export type Rejection = { code: RejectionCode; message: string };
|
export type Rejection = { code: RejectionCode; message: string };
|
||||||
|
|
||||||
|
|||||||
+13
-3
@@ -85,11 +85,20 @@ function candidates(s: GameState, player: PlayerIndex): Intent[] {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The clearance ruling arrives out of turn order and goes to the Superintendent (§8.1).
|
// The two interruptions of the Mainline Phase. Each goes to one named player — `check` is the
|
||||||
if (s.clock.pendingDecision !== null) {
|
// authority on which — so both are generated here and filtered there.
|
||||||
|
if (s.clock.pendingDecision?.kind === 'clearance') {
|
||||||
out.push({ type: 'mainline.clearance', allow: true });
|
out.push({ type: 'mainline.clearance', allow: true });
|
||||||
out.push({ type: 'mainline.clearance', allow: false });
|
out.push({ type: 'mainline.clearance', allow: false });
|
||||||
}
|
}
|
||||||
|
if (s.clock.pendingDecision?.kind === 'yardOffice') {
|
||||||
|
out.push({ type: 'mainline.yardOffice', take: true });
|
||||||
|
out.push({ type: 'mainline.yardOffice', take: false });
|
||||||
|
}
|
||||||
|
if (s.clock.pendingDecision?.kind === 'redFlag') {
|
||||||
|
out.push({ type: 'mainline.redFlag', flag: true });
|
||||||
|
out.push({ type: 'mainline.redFlag', flag: false });
|
||||||
|
}
|
||||||
|
|
||||||
switch (s.clock.phase) {
|
switch (s.clock.phase) {
|
||||||
case 'localOps':
|
case 'localOps':
|
||||||
@@ -110,7 +119,8 @@ function candidates(s: GameState, player: PlayerIndex): Intent[] {
|
|||||||
for (const cardId of s.decks.hands.get(player) ?? []) {
|
for (const cardId of s.decks.hands.get(player) ?? []) {
|
||||||
const k = s.cards.get(cardId)?.kind;
|
const k = s.cards.get(cardId)?.kind;
|
||||||
if (k?.kind !== 'maneuver' || k.key !== 'redFlags') continue;
|
if (k?.kind !== 'maneuver' || k.key !== 'redFlags') continue;
|
||||||
for (const [trayId] of s.trays) out.push({ type: 'maneuver.redFlags', cardId, trayId });
|
// §Q (Gitea#19) — a flag goes on one side of your own district, so the only choice is which.
|
||||||
|
for (const side of ['east', 'west'] as const) out.push({ type: 'maneuver.redFlags', cardId, side });
|
||||||
}
|
}
|
||||||
|
|
||||||
out.push({ type: 'redFlag.play' });
|
out.push({ type: 'redFlag.play' });
|
||||||
|
|||||||
+1
-1
@@ -424,7 +424,7 @@ export function createGame(opts: SetupOptions): GameState {
|
|||||||
phase: 'localOps',
|
phase: 'localOps',
|
||||||
currentActor: superintendent,
|
currentActor: superintendent,
|
||||||
pendingDecision: null,
|
pendingDecision: null,
|
||||||
clearanceRuling: null,
|
decisionAnswer: null,
|
||||||
superintendent,
|
superintendent,
|
||||||
actorOffset: 0,
|
actorOffset: 0,
|
||||||
},
|
},
|
||||||
|
|||||||
+103
-20
@@ -445,20 +445,27 @@ export type CrewTray = {
|
|||||||
position: NodeRef;
|
position: NodeRef;
|
||||||
movesUsed: number;
|
movesUsed: number;
|
||||||
/**
|
/**
|
||||||
* X18 Circus Train — "one turn stopped on any track (circus set-up) earns 1 point", claimed once.
|
* X18 Circus / X17 Campaign — Office Areas this train has already been paid for setting up in
|
||||||
|
* (Gitea#13).
|
||||||
*
|
*
|
||||||
* Recorded on the tray rather than the player because it is the TRAIN that sets up, and an Extra
|
* "Once per stop in an office area. In a multiplayer game, each player could score if the circus
|
||||||
* runs once and is gone; there is no second visit to claim it on.
|
* stops in their area" (Jesse, 2026-08-29). So the claim is per SEAT, not per train: a Circus
|
||||||
|
* touring three districts is paid three times, and one that parks in the same district for six
|
||||||
|
* Stages is paid once.
|
||||||
|
*
|
||||||
|
* Recorded on the tray, which also gives the other half of Jesse's ruling for free — "if the
|
||||||
|
* circus train gets recycled and played a second time as a second extra, then it could again
|
||||||
|
* score points later too". A train is made up onto a FRESH tray object every time, so a re-played
|
||||||
|
* Extra starts with an empty list and no reset code is needed.
|
||||||
*/
|
*/
|
||||||
stopPointClaimed?: boolean;
|
stopPointSeats?: SeatIndex[];
|
||||||
/**
|
/**
|
||||||
* X17 Campaign Train — "one turn at station (speeches) then expedite".
|
* X17 Campaign Train — "one turn at station (speeches) then expedite".
|
||||||
*
|
*
|
||||||
* It makes its speech at the first Office it reaches: that arrival is an ordinary stop, and from
|
* It makes its speech at the first Office it reaches: that arrival is an ordinary stop, and from
|
||||||
* then on the train is expedited — it may be switched normally, but it faults (Q3) if it is left
|
* then on the train is expedited — it may be switched normally, but it faults (Q3) if it is left
|
||||||
* off the Office square when a Mainline Phase begins. Recorded on the tray for the same reason as
|
* off the Office square when a Mainline Phase begins. Recorded on the tray for the same reason as
|
||||||
* `stopPointClaimed` — it is the TRAIN that stops, and an Extra runs once, so there is no later
|
* `stopPointSeats` — it is the TRAIN that stops, and a re-played Extra gets a fresh tray.
|
||||||
* visit to hang it on.
|
|
||||||
*/
|
*/
|
||||||
speechMade?: boolean;
|
speechMade?: boolean;
|
||||||
};
|
};
|
||||||
@@ -518,10 +525,23 @@ export type DivisionNode =
|
|||||||
* "Player sets orientation", so the direction is chosen when the card is placed.
|
* "Player sets orientation", so the direction is chosen when the card is placed.
|
||||||
*/
|
*/
|
||||||
gradeUp?: Direction;
|
gradeUp?: Direction;
|
||||||
/** Red Flags protecting a stopped train here, by tray. */
|
|
||||||
redFlagged?: TrayId[];
|
|
||||||
}
|
}
|
||||||
| { kind: 'office'; seat: SeatIndex };
|
| {
|
||||||
|
kind: 'office';
|
||||||
|
seat: SeatIndex;
|
||||||
|
/**
|
||||||
|
* §Q, RED FLAGS (Gitea#19) — the side of this district a flag is planted on.
|
||||||
|
*
|
||||||
|
* "If played, asked FLAG EAST or FLAG WEST. That stops all trains from entering your limits
|
||||||
|
* from that direction (i.e. Flag East holds westbound trains)." So the value names the SIDE,
|
||||||
|
* and a train arriving from that side is held: a westbound train comes from the east.
|
||||||
|
*
|
||||||
|
* SPENT ON THE TRAIN IT STOPS (Jesse's ruling, 2026-08-29). One card, one train — the flag
|
||||||
|
* comes down as it is used, so there is no lifting action to build, nothing to forget, and a
|
||||||
|
* flag cannot quietly strangle the Division.
|
||||||
|
*/
|
||||||
|
redFlag?: Direction;
|
||||||
|
};
|
||||||
|
|
||||||
/** Ordered west to east. For N players: N Office nodes and N+1 Mainline cards. */
|
/** Ordered west to east. For N players: N Office nodes and N+1 Mainline cards. */
|
||||||
export type Division = { nodes: DivisionNode[] };
|
export type Division = { nodes: DivisionNode[] };
|
||||||
@@ -585,10 +605,42 @@ export type Yards = {
|
|||||||
export type Phase = 'localOps' | 'newTrain' | 'mainline' | 'loadUnload' | 'shiftChange';
|
export type Phase = 'localOps' | 'newTrain' | 'mainline' | 'loadUnload' | 'shiftChange';
|
||||||
|
|
||||||
/** §8.1 fourth condition — the Superintendent rules on a following train. */
|
/** §8.1 fourth condition — the Superintendent rules on a following train. */
|
||||||
export type SuperintendentClearance = {
|
/**
|
||||||
train: TrayId;
|
* AN INTERRUPTION TO THE AUTOMATIC MAINLINE PHASE — a question the driver cannot answer itself.
|
||||||
occupiedBy: TrayId;
|
*
|
||||||
};
|
* There was one of these and it was hardcoded to one question asked of one player: the §8.1
|
||||||
|
* clearance ruling, always to the Superintendent. Gitea#5 and Gitea#19 each need to stop the same
|
||||||
|
* phase and ask a DIFFERENT player something different, so the shape is a union and `decisionActor`
|
||||||
|
* below decides who answers.
|
||||||
|
*
|
||||||
|
* Every member names the `train` the question is about, because the answer has to be matched back
|
||||||
|
* to it — see `DecisionAnswer`.
|
||||||
|
*/
|
||||||
|
export type PendingDecision =
|
||||||
|
/** §8.1 — a following train in the same Subdivision. The Superintendent rules. */
|
||||||
|
| { kind: 'clearance'; train: TrayId; occupiedBy: TrayId }
|
||||||
|
/**
|
||||||
|
* §11 (Gitea#5) — an inbound freight may take the Yard Office instead of the Train Order Office.
|
||||||
|
* Asked of whoever sits in `seat`, on the Mainline Phase the train arrives.
|
||||||
|
*/
|
||||||
|
| { kind: 'yardOffice'; train: TrayId; seat: SeatIndex }
|
||||||
|
/**
|
||||||
|
* §Q (Gitea#19) — a train is about to enter this district into a collision, and its owner holds a
|
||||||
|
* Red Flags card. "You can play the card normally or out of phase, but only if you need it."
|
||||||
|
*/
|
||||||
|
| { kind: 'redFlag'; train: TrayId; seat: SeatIndex; from: Direction };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The answer, waiting to be consumed by the train that asked.
|
||||||
|
*
|
||||||
|
* Without this the driver would re-evaluate the same train, ask the same question, and never
|
||||||
|
* advance. Keyed by `kind` as well as `train` so an answer can never be mistaken for the reply to a
|
||||||
|
* different question about the same train.
|
||||||
|
*/
|
||||||
|
export type DecisionAnswer =
|
||||||
|
| { kind: 'clearance'; train: TrayId; allow: boolean }
|
||||||
|
| { kind: 'yardOffice'; train: TrayId; take: boolean }
|
||||||
|
| { kind: 'redFlag'; train: TrayId; flag: boolean };
|
||||||
|
|
||||||
export type Clock = {
|
export type Clock = {
|
||||||
day: number;
|
day: number;
|
||||||
@@ -597,13 +649,10 @@ export type Clock = {
|
|||||||
phase: Phase;
|
phase: Phase;
|
||||||
/** Exactly one player may act at a time. Null during automatic Mainline movement. */
|
/** Exactly one player may act at a time. Null during automatic Mainline movement. */
|
||||||
currentActor: PlayerIndex | null;
|
currentActor: PlayerIndex | null;
|
||||||
/** Interrupts the Mainline Phase to ask the Superintendent (§8.1). */
|
/** Interrupts the Mainline Phase to ask a player something (§8.1, §11). */
|
||||||
pendingDecision: SuperintendentClearance | null;
|
pendingDecision: PendingDecision | null;
|
||||||
/**
|
/** The answer to `pendingDecision`, waiting to be consumed by the train that asked. */
|
||||||
* The Superintendent's answer, waiting to be consumed by the train that asked. Without this the
|
decisionAnswer: DecisionAnswer | null;
|
||||||
* driver would re-evaluate the same train and ask the same question forever.
|
|
||||||
*/
|
|
||||||
clearanceRuling: { train: TrayId; allow: boolean } | null;
|
|
||||||
superintendent: PlayerIndex;
|
superintendent: PlayerIndex;
|
||||||
/**
|
/**
|
||||||
* How far round the table the current phase has got. Acting order starts at the Superintendent
|
* How far round the table the current phase has got. Acting order starts at the Superintendent
|
||||||
@@ -1131,6 +1180,40 @@ export function playerAtSeat(state: GameState, seat: SeatIndex): PlayerIndex {
|
|||||||
return p;
|
return p;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** This seat's node on the Division — where its Limits, and any Red Flag on them, live. */
|
||||||
|
export function officeNodeFor(
|
||||||
|
state: GameState,
|
||||||
|
seat: SeatIndex,
|
||||||
|
): Extract<DivisionNode, { kind: 'office' }> | null {
|
||||||
|
for (const n of state.division.nodes) if (n.kind === 'office' && n.seat === seat) return n;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WHO MUST ANSWER the interruption, or null when nothing is pending.
|
||||||
|
*
|
||||||
|
* The one place that knows which player each kind of question goes to. §8.1's clearance is the
|
||||||
|
* Superintendent's ruling wherever it happens; the Yard Office is offered to whoever sits in the
|
||||||
|
* district the train is arriving at, because it is their card and their yard.
|
||||||
|
*/
|
||||||
|
export function decisionActor(state: GameState): PlayerIndex | null {
|
||||||
|
const d = state.clock.pendingDecision;
|
||||||
|
if (!d) return null;
|
||||||
|
return d.kind === 'clearance' ? state.clock.superintendent : playerAtSeat(state, d.seat);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WHOSE MOVE IT IS RIGHT NOW — a pending interruption's owner if there is one, else the phase's
|
||||||
|
* own actor.
|
||||||
|
*
|
||||||
|
* Written out six times across the engine, the sim, the web client and the tests as
|
||||||
|
* `pendingDecision !== null ? superintendent : currentActor`, which stopped being right the moment
|
||||||
|
* a second kind of question existed. One copy now, so a new decision kind cannot be half-adopted.
|
||||||
|
*/
|
||||||
|
export function actingPlayer(state: GameState): PlayerIndex | null {
|
||||||
|
return decisionActor(state) ?? state.clock.currentActor;
|
||||||
|
}
|
||||||
|
|
||||||
/** Where this player is sitting, and therefore which Office Area is theirs. */
|
/** Where this player is sitting, and therefore which Office Area is theirs. */
|
||||||
/**
|
/**
|
||||||
* The player `n` seats to the LEFT of this one, wrapping round the table.
|
* The player `n` seats to the LEFT of this one, wrapping round the table.
|
||||||
|
|||||||
+30
-4
@@ -101,7 +101,13 @@ function sendJson(res: ServerResponse, status: number, body: unknown): void {
|
|||||||
res.end(text);
|
res.end(text);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function serveStatic(distDir: string, urlPath: string, res: ServerResponse): Promise<void> {
|
async function serveStatic(
|
||||||
|
distDir: string,
|
||||||
|
urlPath: string,
|
||||||
|
res: ServerResponse,
|
||||||
|
/** The request's `?v=` build tag, when it has one — see the `Cache-Control` note below. */
|
||||||
|
buildTagged = false,
|
||||||
|
): Promise<void> {
|
||||||
const rel = urlPath === '/' ? '/index.html' : urlPath;
|
const rel = urlPath === '/' ? '/index.html' : urlPath;
|
||||||
// `normalize` collapses `..`, and the join is then checked to still be inside `distDir` — a request
|
// `normalize` collapses `..`, and the join is then checked to still be inside `distDir` — a request
|
||||||
// for `/../../etc/passwd` must not escape the one directory this is allowed to read from.
|
// for `/../../etc/passwd` must not escape the one directory this is allowed to read from.
|
||||||
@@ -113,7 +119,27 @@ async function serveStatic(distDir: string, urlPath: string, res: ServerResponse
|
|||||||
try {
|
try {
|
||||||
const info = await stat(full);
|
const info = await stat(full);
|
||||||
if (!info.isFile()) throw new Error('not a file');
|
if (!info.isFile()) throw new Error('not a file');
|
||||||
res.writeHead(200, { 'Content-Type': MIME[extname(full)] ?? 'application/octet-stream', 'Content-Length': info.size });
|
/**
|
||||||
|
* ONLY A URL CARRYING A BUILD TAG MAY BE CACHED, AND NOTHING ELSE MAY BE.
|
||||||
|
*
|
||||||
|
* Nothing here sent a `Cache-Control` at all before, so a browser applied its own heuristic to
|
||||||
|
* the pages as much as the modules. The pages are the one thing that CANNOT be versioned in
|
||||||
|
* their own URL — a player types the address or follows a bookmark — so a cached `play.html`
|
||||||
|
* pins that player to the entire build it names, including every `?v=` tag inside it. That is
|
||||||
|
* half of why v0.7.5 and v0.7.6 did not reach the browser that asked for them; `build-web.ts`
|
||||||
|
* publishing `?v=nogit` on every packaged release was the other half, and neither is enough on
|
||||||
|
* its own.
|
||||||
|
*
|
||||||
|
* `?v=` is the exact condition rather than "not HTML": `build-web.ts` tags the modules and the
|
||||||
|
* script tags that load them, and tags NOTHING else. An untagged URL — an image, the replay
|
||||||
|
* manifest — has no way to announce a change, so a year of `immutable` on one would outlive
|
||||||
|
* several releases of whatever it holds.
|
||||||
|
*/
|
||||||
|
res.writeHead(200, {
|
||||||
|
'Content-Type': MIME[extname(full)] ?? 'application/octet-stream',
|
||||||
|
'Content-Length': info.size,
|
||||||
|
'Cache-Control': buildTagged ? 'public, max-age=31536000, immutable' : 'no-cache',
|
||||||
|
});
|
||||||
createReadStream(full).pipe(res);
|
createReadStream(full).pipe(res);
|
||||||
} catch {
|
} catch {
|
||||||
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
||||||
@@ -281,7 +307,7 @@ export function startServer(opts: ServerOptions): void {
|
|||||||
// Unset means the routes are not here — indistinguishable from any other unknown path, so
|
// Unset means the routes are not here — indistinguishable from any other unknown path, so
|
||||||
// nothing advertises an administrative surface to someone probing for one.
|
// nothing advertises an administrative surface to someone probing for one.
|
||||||
if (!opts.adminSecret) {
|
if (!opts.adminSecret) {
|
||||||
await serveStatic(opts.distDir, url.pathname, res);
|
await serveStatic(opts.distDir, url.pathname, res, url.searchParams.has('v'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (req.headers['x-admin-secret'] !== opts.adminSecret) {
|
if (req.headers['x-admin-secret'] !== opts.adminSecret) {
|
||||||
@@ -700,7 +726,7 @@ export function startServer(opts: ServerOptions): void {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await serveStatic(opts.distDir, url.pathname, res);
|
await serveStatic(opts.distDir, url.pathname, res, url.searchParams.has('v'));
|
||||||
})().catch((err: unknown) => {
|
})().catch((err: unknown) => {
|
||||||
sendJson(res, 500, { error: err instanceof Error ? err.message : 'internal error' });
|
sendJson(res, 500, { error: err instanceof Error ? err.message : 'internal error' });
|
||||||
});
|
});
|
||||||
|
|||||||
+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
|
||||||
|
|||||||
+29
-16
@@ -35,7 +35,7 @@ import type { Intent } from '../engine/intents.ts';
|
|||||||
import { legalActions } from '../engine/legal.ts';
|
import { legalActions } from '../engine/legal.ts';
|
||||||
import { connectionsFor, exitsFrom, facilityVariants, hasPort, joins, neighbour, opposite, variantsFor } from '../engine/track.ts';
|
import { connectionsFor, exitsFrom, facilityVariants, hasPort, joins, neighbour, opposite, variantsFor } from '../engine/track.ts';
|
||||||
import type { Port } from '../engine/track.ts';
|
import type { Port } from '../engine/track.ts';
|
||||||
import { coordKey, turnOf } from '../engine/state.ts';
|
import { actingPlayer, coordKey, turnOf } from '../engine/state.ts';
|
||||||
import type { Facility, GameState, GridCoord, OfficeArea, PlayerIndex, RollingStock, TrackCard } from '../engine/state.ts';
|
import type { Facility, GameState, GridCoord, OfficeArea, PlayerIndex, RollingStock, TrackCard } from '../engine/state.ts';
|
||||||
|
|
||||||
export type BotPolicy = {
|
export type BotPolicy = {
|
||||||
@@ -154,10 +154,23 @@ export function makeDeveloperBot(tweaks: BotTweaks): BotPolicy {
|
|||||||
const clearance = ruleOnClearance(options);
|
const clearance = ruleOnClearance(options);
|
||||||
if (clearance) return because('the Superintendent must rule on a following train', clearance);
|
if (clearance) return because('the Superintendent must rule on a following train', clearance);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* §11 (Gitea#5) — the bot keeps its trains at the Train Order Office.
|
||||||
|
*
|
||||||
|
* A deliberate policy, not an oversight, and the cautious half of a real choice: the Yard Office
|
||||||
|
* frees an A/D track, which is worth something on a busy district, but the lead into it may be
|
||||||
|
* fouled and the bot does not read its own yard well enough to tell (`TODO.md`, Bot
|
||||||
|
* Performance — it cannot spot a car at a stub industry either). Declining is always safe, and
|
||||||
|
* it keeps the balance harness comparable with every measurement taken before this rule existed.
|
||||||
|
* Worth revisiting when the bot can judge the lead.
|
||||||
|
*/
|
||||||
|
const yardOffice = options.find((i) => i.type === 'mainline.yardOffice' && i.take === false);
|
||||||
|
if (yardOffice) return because('the bot does not judge the lead into a yard, so it stays at the Office', yardOffice);
|
||||||
|
|
||||||
// Red Flags come before anything else — protection is only worth playing at the moment the
|
// Red Flags come before anything else — protection is only worth playing at the moment the
|
||||||
// collision is actually pending, and that moment passes.
|
// collision is actually pending, and that moment passes.
|
||||||
const flags = worthFlagging(s, options);
|
const flags = worthFlagging(s, options);
|
||||||
if (flags) return because('a train of ours is stopped on a Mainline card with another train on it — Red Flags now or not at all', flags);
|
if (flags) return because('the engine says this arrival collides, and we hold a Red Flag — now or never', flags);
|
||||||
|
|
||||||
// --- Load/Unload: spend every worker, then end. Each is a point, or a step toward one.
|
// --- Load/Unload: spend every worker, then end. Each is a point, or a step toward one.
|
||||||
//
|
//
|
||||||
@@ -1030,19 +1043,20 @@ function facilityWantsAt(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Red Flags — "any time". Worth spending only when a train of ours is stopped out on the Mainline
|
* §Q, RED FLAGS (Gitea#19) — spent only at the moment of danger.
|
||||||
* with another train on the same card, which is the situation that becomes a rear-ender.
|
*
|
||||||
|
* The card was redefined: it plants a directional flag on your own Limits rather than protecting a
|
||||||
|
* stopped train out on the Mainline, so the old heuristic ("is a train of ours sharing a Mainline
|
||||||
|
* card") no longer describes anything the card does.
|
||||||
|
*
|
||||||
|
* The bot now flags ONLY through the out-of-phase prompt, which the engine raises exactly when an
|
||||||
|
* arrival would collide (`redFlagStop`). That is a better policy than the old one and a much
|
||||||
|
* simpler one: the engine has already established the danger, so there is nothing for the bot to
|
||||||
|
* judge. It never plants a flag speculatively — it cannot tell whether it wants time to switch, and
|
||||||
|
* a flag spent early is a flag not there when a train is actually bearing down.
|
||||||
*/
|
*/
|
||||||
function worthFlagging(s: GameState, options: Intent[]): Intent | null {
|
function worthFlagging(_s: GameState, options: Intent[]): Intent | null {
|
||||||
for (const i of options) {
|
return options.find((i) => i.type === 'mainline.redFlag' && i.flag === true) ?? null;
|
||||||
if (i.type !== 'maneuver.redFlags') continue;
|
|
||||||
const tray = s.trays.get(i.trayId);
|
|
||||||
if (!tray || tray.position.at !== 'mainline') continue;
|
|
||||||
const node = s.division.nodes[tray.position.index];
|
|
||||||
if (node?.kind !== 'mainline') continue;
|
|
||||||
if (node.transits.length > 1) return i;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A one-line account of which Load/Unload action was taken, and why it ranked first. */
|
/** A one-line account of which Load/Unload action was taken, and why it ranked first. */
|
||||||
@@ -1904,8 +1918,7 @@ export function playGame(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const actor =
|
const actor = actingPlayer(s);
|
||||||
s.clock.pendingDecision !== null ? s.clock.superintendent : s.clock.currentActor;
|
|
||||||
if (actor === null) break;
|
if (actor === null) break;
|
||||||
|
|
||||||
const options = legalActions(s, actor);
|
const options = legalActions(s, actor);
|
||||||
|
|||||||
+16
-1
@@ -218,10 +218,19 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
|
|||||||
? `Realignment: Mainline card ${e.node} converted to ${e.became}`
|
? `Realignment: Mainline card ${e.node} converted to ${e.became}`
|
||||||
: `Played ${e.key} on Mainline card ${e.node}`,
|
: `Played ${e.key} on Mainline card ${e.node}`,
|
||||||
};
|
};
|
||||||
|
case 'redFlagSpent':
|
||||||
|
return {
|
||||||
|
tone: 'good',
|
||||||
|
text: `RED FLAG — Train ${e.trainNumber} stopped short of the ${e.side === 'east' ? 'Eastern' : 'Western'} Limits. The flag comes down with it.`,
|
||||||
|
};
|
||||||
|
case 'redFlagRuled':
|
||||||
|
return e.flag
|
||||||
|
? { tone: 'plain', text: `Player ${e.player} flagged the approaching train` }
|
||||||
|
: { tone: 'plain', text: `Player ${e.player} waved the train through` };
|
||||||
case 'redFlagsSet':
|
case 'redFlagsSet':
|
||||||
return {
|
return {
|
||||||
tone: 'good',
|
tone: 'good',
|
||||||
text: `Red Flags set out to protect train ${e.trayId} on Mainline card ${e.node} — an approaching train must stop`,
|
text: `RED FLAGS set out on the ${e.side === 'east' ? 'Eastern' : 'Western'} Limits — the next train from that way is held short`,
|
||||||
};
|
};
|
||||||
case 'flyingSwitch':
|
case 'flyingSwitch':
|
||||||
return {
|
return {
|
||||||
@@ -498,6 +507,12 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
|
|||||||
return { tone: 'clock', text: `── The table plays on: Day ${e.day} is added to the timetable ──` };
|
return { tone: 'clock', text: `── The table plays on: Day ${e.day} is added to the timetable ──` };
|
||||||
case 'playConcluded':
|
case 'playConcluded':
|
||||||
return { tone: 'clock', text: '── The railroad is put to bed. Final results stand. ──' };
|
return { tone: 'clock', text: '── The railroad is put to bed. Final results stand. ──' };
|
||||||
|
|
||||||
|
// -- §11, the Yard Office (Gitea#5)
|
||||||
|
case 'yardOfficeRuled':
|
||||||
|
return e.take
|
||||||
|
? { tone: 'plain', text: `Player ${e.player} sent ${train(e.trainId)} into the Yard Office` }
|
||||||
|
: { tone: 'plain', text: `Player ${e.player} kept ${train(e.trainId)} at the Train Order Office` };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+18
-2
@@ -34,6 +34,8 @@ import type { Intent } from '../engine/intents.ts';
|
|||||||
import { legalActions } from '../engine/legal.ts';
|
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 { 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
|
||||||
@@ -135,7 +137,7 @@ export function record(seed: number, length: GameLength, maxSteps = 100_000): Re
|
|||||||
if (s.status === 'finished') break;
|
if (s.status === 'finished') break;
|
||||||
if (!r.needsInput) continue;
|
if (!r.needsInput) continue;
|
||||||
|
|
||||||
const actor = s.clock.pendingDecision !== null ? s.clock.superintendent : s.clock.currentActor;
|
const actor = actingPlayer(s);
|
||||||
if (actor === null) break;
|
if (actor === null) break;
|
||||||
const options = legalActions(s, actor);
|
const options = legalActions(s, actor);
|
||||||
if (options.length === 0) break;
|
if (options.length === 0) break;
|
||||||
@@ -147,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;
|
||||||
|
|||||||
+69
-6
@@ -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;
|
||||||
/**
|
/**
|
||||||
@@ -1043,7 +1057,24 @@ export function describeIntent(s: GameState, i: Intent): string {
|
|||||||
return `${cardName(s, i.cardId)} on ${shortWhere} — ${where}${effect ? `; ${effect}` : ''}`;
|
return `${cardName(s, i.cardId)} on ${shortWhere} — ${where}${effect ? `; ${effect}` : ''}`;
|
||||||
}
|
}
|
||||||
case 'maneuver.redFlags':
|
case 'maneuver.redFlags':
|
||||||
return `set Red Flags to protect ${trainName(s, i.trayId)} — an approaching train must stop short`;
|
return (
|
||||||
|
`FLAG ${i.side === 'east' ? 'EAST' : 'WEST'} — hold the next ${i.side === 'east' ? 'westbound' : 'eastbound'} ` +
|
||||||
|
'train short of your Limits, so you can finish switching'
|
||||||
|
);
|
||||||
|
// §Q, the out-of-phase play (Gitea#19) — "COLLISION RISK! FLAG AGAINST T2?"
|
||||||
|
case 'mainline.redFlag':
|
||||||
|
return i.flag
|
||||||
|
? 'FLAG IT — stop the train short of your Limits, spending a Red Flags card'
|
||||||
|
: 'wave it through — let it come in';
|
||||||
|
/**
|
||||||
|
* §11, the Yard Office (Gitea#5). The offer interrupts the Mainline Phase, so the label has to
|
||||||
|
* carry the whole question — there is no surrounding context on screen to lean on, and the
|
||||||
|
* player is being asked about a train they were not otherwise thinking about.
|
||||||
|
*/
|
||||||
|
case 'mainline.yardOffice':
|
||||||
|
return i.take
|
||||||
|
? 'take the YARD OFFICE — straight into the yard, leaving the Train Order Office free'
|
||||||
|
: 'keep it at the Train Order Office — the ordinary arrival, onto an A/D track';
|
||||||
// §3.3, extended play (Gitea#11). The results screen draws its own buttons, but a bot reads its
|
// §3.3, extended play (Gitea#11). The results screen draws its own buttons, but a bot reads its
|
||||||
// options through this list like any other, and the label is what the history says it chose.
|
// options through this list like any other, and the label is what the history says it chose.
|
||||||
case 'game.extend':
|
case 'game.extend':
|
||||||
@@ -1055,9 +1086,12 @@ export function describeIntent(s: GameState, i: Intent): string {
|
|||||||
case 'mainline.clearance': {
|
case 'mainline.clearance': {
|
||||||
// The §8.1 ruling is the sharpest decision in the game and read "grant clearance" — no hint
|
// The §8.1 ruling is the sharpest decision in the game and read "grant clearance" — no hint
|
||||||
// that granting it risks a rear-ender, or that refusing merely costs time.
|
// that granting it risks a rear-ender, or that refusing merely costs time.
|
||||||
|
// Narrowed to the clearance question: `pendingDecision` is a union since Gitea#5, and only
|
||||||
|
// this member names a train ahead.
|
||||||
const pending = s.clock.pendingDecision;
|
const pending = s.clock.pendingDecision;
|
||||||
const who = pending ? trainName(s, pending.train) : 'the train';
|
const clearance = pending?.kind === 'clearance' ? pending : null;
|
||||||
const ahead = pending ? trainName(s, pending.occupiedBy) : 'the train ahead';
|
const who = clearance ? trainName(s, clearance.train) : 'the train';
|
||||||
|
const ahead = clearance ? trainName(s, clearance.occupiedBy) : 'the train ahead';
|
||||||
// NOT "risks a collision, −5". A rear-end on a Mainline card is described by §10 and is what
|
// NOT "risks a collision, −5". A rear-end on a Mainline card is described by §10 and is what
|
||||||
// ABS Signals exists to prevent, but no such collision is implemented — granting clearance is
|
// ABS Signals exists to prevent, but no such collision is implemented — granting clearance is
|
||||||
// currently free. Saying otherwise invents a consequence the engine will never deliver.
|
// currently free. Saying otherwise invents a consequence the engine will never deliver.
|
||||||
@@ -1097,7 +1131,24 @@ export function describeIntent(s: GameState, i: Intent): string {
|
|||||||
case 'redFlag.play':
|
case 'redFlag.play':
|
||||||
return 'play your red flag';
|
return 'play your red flag';
|
||||||
case 'maneuver.redFlags':
|
case 'maneuver.redFlags':
|
||||||
return `set Red Flags to protect ${trainName(s, i.trayId)} — an approaching train must stop short`;
|
return (
|
||||||
|
`FLAG ${i.side === 'east' ? 'EAST' : 'WEST'} — hold the next ${i.side === 'east' ? 'westbound' : 'eastbound'} ` +
|
||||||
|
'train short of your Limits, so you can finish switching'
|
||||||
|
);
|
||||||
|
// §Q, the out-of-phase play (Gitea#19) — "COLLISION RISK! FLAG AGAINST T2?"
|
||||||
|
case 'mainline.redFlag':
|
||||||
|
return i.flag
|
||||||
|
? 'FLAG IT — stop the train short of your Limits, spending a Red Flags card'
|
||||||
|
: 'wave it through — let it come in';
|
||||||
|
/**
|
||||||
|
* §11, the Yard Office (Gitea#5). The offer interrupts the Mainline Phase, so the label has to
|
||||||
|
* carry the whole question — there is no surrounding context on screen to lean on, and the
|
||||||
|
* player is being asked about a train they were not otherwise thinking about.
|
||||||
|
*/
|
||||||
|
case 'mainline.yardOffice':
|
||||||
|
return i.take
|
||||||
|
? 'take the YARD OFFICE — straight into the yard, leaving the Train Order Office free'
|
||||||
|
: 'keep it at the Train Order Office — the ordinary arrival, onto an A/D track';
|
||||||
// §3.3, extended play (Gitea#11). The results screen draws its own buttons, but a bot reads its
|
// §3.3, extended play (Gitea#11). The results screen draws its own buttons, but a bot reads its
|
||||||
// options through this list like any other, and the label is what the history says it chose.
|
// options through this list like any other, and the label is what the history says it chose.
|
||||||
case 'game.extend':
|
case 'game.extend':
|
||||||
@@ -1305,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,
|
||||||
|
|||||||
+76
-15
@@ -30,6 +30,7 @@ import type { Intent } from '../engine/intents.ts';
|
|||||||
import { legalActions } from '../engine/legal.ts';
|
import { legalActions } from '../engine/legal.ts';
|
||||||
import { createGame } from '../engine/setup.ts';
|
import { createGame } from '../engine/setup.ts';
|
||||||
import type { CardId, GameConfig, GameState, PlayerIndex } from '../engine/state.ts';
|
import type { CardId, GameConfig, GameState, PlayerIndex } from '../engine/state.ts';
|
||||||
|
import { actingPlayer } from '../engine/state.ts';
|
||||||
import { playerAtSeat } from '../engine/state.ts';
|
import { playerAtSeat } from '../engine/state.ts';
|
||||||
import { cuesFor, narrate } from '../sim/narrate.ts';
|
import { cuesFor, narrate } from '../sim/narrate.ts';
|
||||||
// Import from the view module, NOT replay.ts — replay.ts writes files and reads process.argv,
|
// Import from the view module, NOT replay.ts — replay.ts writes files and reads process.argv,
|
||||||
@@ -133,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 ?? {}) },
|
||||||
@@ -266,6 +282,7 @@ export type Game = {
|
|||||||
/** How each intent kind is introduced in the action list, in the order they should appear. */
|
/** How each intent kind is introduced in the action list, in the order they should appear. */
|
||||||
const GROUP_ORDER: readonly { prefix: string; title: string }[] = [
|
const GROUP_ORDER: readonly { prefix: string; title: string }[] = [
|
||||||
{ prefix: 'mainline.clearance', title: 'Superintendent — rule on this train' },
|
{ prefix: 'mainline.clearance', title: 'Superintendent — rule on this train' },
|
||||||
|
{ prefix: 'mainline.yardOffice', title: 'Where does this train arrive?' },
|
||||||
{ prefix: 'localOps.choose', title: 'Local Operations — choose ONE' },
|
{ prefix: 'localOps.choose', title: 'Local Operations — choose ONE' },
|
||||||
{ prefix: 'switch.', title: 'Switching' },
|
{ prefix: 'switch.', title: 'Switching' },
|
||||||
// Specific before general: `startsWith` means a bare `draw.` would swallow all three, and the
|
// Specific before general: `startsWith` means a bare `draw.` would swallow all three, and the
|
||||||
@@ -334,9 +351,9 @@ export function drain(game: Game): void {
|
|||||||
/** Whose turn it is, or null if the game is over or waiting on nothing. */
|
/** Whose turn it is, or null if the game is over or waiting on nothing. */
|
||||||
export function currentActor(game: Game): PlayerIndex | null {
|
export function currentActor(game: Game): PlayerIndex | null {
|
||||||
if (game.state.status !== 'active') return null;
|
if (game.state.status !== 'active') return null;
|
||||||
return game.state.clock.pendingDecision !== null
|
// `actingPlayer` (state.ts) knows which player each kind of interruption goes to — the
|
||||||
? game.state.clock.superintendent
|
// Superintendent for a §8.1 clearance, the district's owner for a Yard Office offer.
|
||||||
: game.state.clock.currentActor;
|
return actingPlayer(game.state);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Every legal action right now, grouped for display. Empty when there is nothing to decide. */
|
/** Every legal action right now, grouped for display. Empty when there is nothing to decide. */
|
||||||
@@ -465,13 +482,26 @@ export function actionGroups(game: Game): { options: Intent[]; groups: ActionGro
|
|||||||
*/
|
*/
|
||||||
if (prefix === 'mainline.clearance') {
|
if (prefix === 'mainline.clearance') {
|
||||||
const pending = game.state.clock.pendingDecision;
|
const pending = game.state.clock.pendingDecision;
|
||||||
if (pending) {
|
if (pending?.kind === 'clearance') {
|
||||||
headed =
|
headed =
|
||||||
`Superintendent — may ${trainName(game.state, pending.train)} follow ` +
|
`Superintendent — may ${trainName(game.state, pending.train)} follow ` +
|
||||||
`${trainName(game.state, pending.occupiedBy)} onto the same Mainline card?`;
|
`${trainName(game.state, pending.occupiedBy)} onto the same Mainline card?`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* §11 (Gitea#5) — the Yard Office offer interrupts the Mainline Phase, so it arrives with no
|
||||||
|
* context around it: the player was not thinking about this train a moment ago.
|
||||||
|
*/
|
||||||
|
if (prefix === 'mainline.yardOffice') {
|
||||||
|
const pending = game.state.clock.pendingDecision;
|
||||||
|
if (pending?.kind === 'yardOffice') {
|
||||||
|
headed =
|
||||||
|
`${trainName(game.state, pending.train)} is arriving with no coaches — ` +
|
||||||
|
'take it into the Yard Office, or hold it at the Train Order Office?';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A PENDING EXTRA IS ITS OWN QUESTION, and its own heading.
|
* A PENDING EXTRA IS ITS OWN QUESTION, and its own heading.
|
||||||
*
|
*
|
||||||
@@ -1092,6 +1122,24 @@ export function view(game: Game, seat: PlayerIndex = 0): Frame {
|
|||||||
return snapshot(game.state, [], null, null, null, false, seat);
|
return snapshot(game.state, [], null, null, null, false, seat);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fold a narration's opening word into the middle of a sentence — "Chose to draw" after a name has
|
||||||
|
* to read "Player Bob chose to draw".
|
||||||
|
*
|
||||||
|
* ONLY A SENTENCE-CASED WORD, which is the whole point. It used to be a flat
|
||||||
|
* `text.charAt(0).toLowerCase()`, so every line opening with an all-caps keyword came out mangled:
|
||||||
|
* `EXTRA X18 started…` rendered as `Player Solitaire eXTRA X18 started…`, and the same happened to
|
||||||
|
* `TRAIN 1 MADE UP` and `COLLISION`. Those words are shouted deliberately.
|
||||||
|
*
|
||||||
|
* `^[A-Z][a-z]` is the test — a capital followed by a lower-case letter is an ordinary word that was
|
||||||
|
* capitalised because it began a sentence, and nothing else is. It leaves all-caps keywords alone,
|
||||||
|
* and it also leaves alone a word whose second character is a digit or a hyphen (`X22 Pee-Dee`),
|
||||||
|
* which a naive "is it uppercase?" check would get wrong because `'2'.toUpperCase() === '2'`.
|
||||||
|
*/
|
||||||
|
function uncapitalise(text: string): string {
|
||||||
|
return /^[A-Z][a-z]/.test(text) ? text.charAt(0).toLowerCase() + text.slice(1) : text;
|
||||||
|
}
|
||||||
|
|
||||||
function record(game: Game, events: GameEvent[], actor: PlayerIndex | null = null): void {
|
function record(game: Game, events: GameEvent[], actor: PlayerIndex | null = null): void {
|
||||||
const who = actor === null ? null : (game.state.players[actor]?.name ?? null);
|
const who = actor === null ? null : (game.state.players[actor]?.name ?? null);
|
||||||
for (const e of events) {
|
for (const e of events) {
|
||||||
@@ -1104,7 +1152,7 @@ function record(game: Game, events: GameEvent[], actor: PlayerIndex | null = nul
|
|||||||
// "Chose to DRAW a card" does not say WHO, which is unreadable the moment there is more than
|
// "Chose to DRAW a card" does not say WHO, which is unreadable the moment there is more than
|
||||||
// one seat. Only events the player caused are attributed; the Division running itself is not.
|
// one seat. Only events the player caused are attributed; the Division running itself is not.
|
||||||
const mine = who !== null && 'player' in e;
|
const mine = who !== null && 'player' in e;
|
||||||
const text = mine ? `Player ${who} ${n.text.charAt(0).toLowerCase()}${n.text.slice(1)}` : n.text;
|
const text = mine ? `Player ${who} ${uncapitalise(n.text)}` : n.text;
|
||||||
game.log.push({ text, tone: mine ? 'act' : n.tone });
|
game.log.push({ text, tone: mine ? 'act' : n.tone });
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -1205,7 +1253,18 @@ export function fromSave(save: Save, config: GameConfig = SOLO_CONFIG): Game {
|
|||||||
const result = applyIntent(game.state, actor, intent);
|
const result = applyIntent(game.state, actor, intent);
|
||||||
if (!result.ok) break;
|
if (!result.ok) break;
|
||||||
game.history.push(intent);
|
game.history.push(intent);
|
||||||
record(game, result.events);
|
/**
|
||||||
|
* `actor` IS PASSED HERE, so a replayed game narrates exactly as the live one did.
|
||||||
|
*
|
||||||
|
* It was omitted, and the omission was invisible in solitaire for a reason worth keeping: the
|
||||||
|
* only test that compares logs ("leaves nothing in the log describing a move that was taken
|
||||||
|
* back") compares one `fromSave`-built log against ANOTHER, so the missing attribution cancelled
|
||||||
|
* out on both sides. Live play attributes (`submit` passes `actor`) and so does multiplayer's
|
||||||
|
* replay (`fromMultiplayerSave`) — this was the one path of the three that did not, which meant
|
||||||
|
* a restored save, an undone game (undo rebuilds through here) and the replay viewer all
|
||||||
|
* described the same moves in different words from the game that produced them.
|
||||||
|
*/
|
||||||
|
record(game, result.events, actor);
|
||||||
drain(game);
|
drain(game);
|
||||||
}
|
}
|
||||||
return game;
|
return game;
|
||||||
@@ -1219,16 +1278,18 @@ export function fromSave(save: Save, config: GameConfig = SOLO_CONFIG): Game {
|
|||||||
* this function only ever reconstructs from history that is already known to have been recorded
|
* this function only ever reconstructs from history that is already known to have been recorded
|
||||||
* under the currently-running rules.
|
* under the currently-running rules.
|
||||||
*
|
*
|
||||||
* UNLIKE `fromSave`'s loop, this passes `actor` to `record()` (matching `submit`'s own call,
|
* LIKE `fromSave`'s loop, this passes `actor` to `record()` (matching `submit`'s own call,
|
||||||
* `game.ts` above) — found while testing Phase 3's resume path: without it, every replayed line loses
|
* `game.ts` above) — found while testing Phase 3's resume path: without it, every replayed line loses
|
||||||
* its "Player X" attribution and reads as anonymous "Chose to..." narration, which `record`'s own
|
* its "Player X" attribution and reads as anonymous "Chose to..." narration, which `record`'s own
|
||||||
* comment calls "unreadable the moment there is more than one seat" — exactly the multiplayer case a
|
* comment calls "unreadable the moment there is more than one seat" — exactly the multiplayer case a
|
||||||
* resumed game hits every time. `fromSave` has the same gap (it predates multiplayer and nothing ever
|
* resumed game hits every time.
|
||||||
* compares its output against a LIVE-played log, so it has gone unnoticed — `undo`'s rebuilt game is
|
*
|
||||||
* itself `fromSave`-built, so `test/web.test.ts`'s replay-fidelity test only ever compares one
|
* `fromSave` HAD THE SAME GAP AND NO LONGER DOES (fixed 2026-08-30). It predated multiplayer, and
|
||||||
* unattributed replay against another). Flagged in `TODO.md` rather than fixed there in this pass —
|
* nothing ever compared its output against a LIVE-played log: `undo`'s rebuilt game is itself
|
||||||
* out of scope for Phase 3 and used far more widely, so worth its own careful look rather than a
|
* `fromSave`-built, so `test/web.test.ts`'s replay-fidelity test only ever compared one unattributed
|
||||||
* touch-in-passing.
|
* replay against another and the gap cancelled out on both sides. The test that now pins it plays a
|
||||||
|
* game live, restores it from its own save, and asserts the two logs are identical — which is the
|
||||||
|
* comparison that had been missing rather than a new requirement.
|
||||||
*/
|
*/
|
||||||
/**
|
/**
|
||||||
* Why the intent a replay stopped at is reported rather than swallowed.
|
* Why the intent a replay stopped at is reported rather than swallowed.
|
||||||
|
|||||||
+5
-5
@@ -79,13 +79,13 @@ footer{margin-top:26px;color:var(--dim);font-size:11px;display:flex;gap:18px;fle
|
|||||||
<span class="go" id="door-multiplayer-go">Set up a game →</span>
|
<span class="go" id="door-multiplayer-go">Set up a game →</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<a class="door" href="./play.html">
|
<a class="door" href="./play.html?solitaire">
|
||||||
<h2>Play solitaire</h2>
|
<h2>Play solitaire</h2>
|
||||||
<p>Play by yourself and run the entire division for five full days. Your goal is 20 Revenue.
|
<p>Play by yourself and run the entire division for five full days. Clear the Revenue floor of
|
||||||
Your game data is saved in your browser — if you close the tab and reopen this site
|
15 by the end or the game is a loss. Your game data is saved in your browser — if you close the tab and reopen this site
|
||||||
without clearing your cache, your game is preserved and you can continue automatically.
|
without clearing your cache, your game is preserved and you can continue automatically.
|
||||||
During the game you can also explicitly save your progress for later replay.</p>
|
During the game you can also explicitly save your progress for later replay.</p>
|
||||||
<span class="go">Start a game →</span>
|
<span class="go">Set up a game →</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<a class="door" href="./replays.html">
|
<a class="door" href="./replays.html">
|
||||||
@@ -100,7 +100,7 @@ footer{margin-top:26px;color:var(--dim);font-size:11px;display:flex;gap:18px;fle
|
|||||||
|
|
||||||
<footer>
|
<footer>
|
||||||
<span>build <span id="build">__BUILD__</span></span>
|
<span>build <span id="build">__BUILD__</span></span>
|
||||||
<span>solitaire runs entirely in your browser — no server code required</span>
|
<span>Multiplayer runs on StartOS server. Solitaire runs entirely in your browser.</span>
|
||||||
</footer>
|
</footer>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|||||||
+17
-24
@@ -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.
|
||||||
$<HTMLDetailsElement>('lb-settings').open = true;
|
if (type === 'custom') $<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 {
|
||||||
|
|||||||
+481
-174
@@ -36,7 +36,8 @@ import {
|
|||||||
settingsOf,
|
settingsOf,
|
||||||
} from './presets.ts';
|
} from './presets.ts';
|
||||||
import type { GameType, PresetName } from './presets.ts';
|
import type { GameType, PresetName } from './presets.ts';
|
||||||
import { settingsForm } from './settings-form.ts';
|
import { rulesListHtml, settingsForm } from './settings-form.ts';
|
||||||
|
import type { SettingsForm } from './settings-form.ts';
|
||||||
|
|
||||||
const SAVE_KEY = 'station-master.save.v1';
|
const SAVE_KEY = 'station-master.save.v1';
|
||||||
const SETTINGS_KEY = 'station-master.settings.v1';
|
const SETTINGS_KEY = 'station-master.settings.v1';
|
||||||
@@ -61,9 +62,14 @@ type Settings = {
|
|||||||
districtMode: 'auto' | 'open' | 'closed';
|
districtMode: 'auto' | 'open' | 'closed';
|
||||||
soundOn: boolean;
|
soundOn: boolean;
|
||||||
zoom: number;
|
zoom: number;
|
||||||
|
/**
|
||||||
|
* Is the This Game card open? (TODO #28.) Folded by default: it answers "what did we set that
|
||||||
|
* to?", which Jesse's own framing says is "not something they're likely to need all the time".
|
||||||
|
*/
|
||||||
|
gameCardOpen: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_SETTINGS: Settings = { districtMode: 'auto', soundOn: false, zoom: 1 };
|
const DEFAULT_SETTINGS: Settings = { districtMode: 'auto', soundOn: false, zoom: 1, gameCardOpen: false };
|
||||||
|
|
||||||
function loadSettings(): Settings {
|
function loadSettings(): Settings {
|
||||||
try {
|
try {
|
||||||
@@ -79,6 +85,8 @@ function loadSettings(): Settings {
|
|||||||
typeof parsed.zoom === 'number' && (ZOOM_LEVELS as readonly number[]).includes(parsed.zoom)
|
typeof parsed.zoom === 'number' && (ZOOM_LEVELS as readonly number[]).includes(parsed.zoom)
|
||||||
? parsed.zoom
|
? parsed.zoom
|
||||||
: DEFAULT_SETTINGS.zoom,
|
: DEFAULT_SETTINGS.zoom,
|
||||||
|
gameCardOpen:
|
||||||
|
typeof parsed.gameCardOpen === 'boolean' ? parsed.gameCardOpen : DEFAULT_SETTINGS.gameCardOpen,
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
// A full or disabled localStorage must not take the game down with it — same guard as the save.
|
// A full or disabled localStorage must not take the game down with it — same guard as the save.
|
||||||
@@ -140,6 +148,7 @@ let pendingAt: string | null = null;
|
|||||||
* save, for exactly that reason.
|
* save, for exactly that reason.
|
||||||
*/
|
*/
|
||||||
let districtMode: 'auto' | 'open' | 'closed' = settings.districtMode;
|
let districtMode: 'auto' | 'open' | 'closed' = settings.districtMode;
|
||||||
|
let gameCardOpen = settings.gameCardOpen;
|
||||||
/**
|
/**
|
||||||
* Sound, OFF by default until a player asks for it once — then remembered via `settings`.
|
* Sound, OFF by default until a player asks for it once — then remembered via `settings`.
|
||||||
*
|
*
|
||||||
@@ -256,17 +265,88 @@ function renderTurnChart(f: Frame): void {
|
|||||||
* tooltip. Written down at all because a playtest note is worthless without it: "scored 4" means one
|
* tooltip. Written down at all because a playtest note is worthless without it: "scored 4" means one
|
||||||
* thing at 1 Revenue per transit and another at 5.
|
* thing at 1 Revenue per transit and another at 5.
|
||||||
*/
|
*/
|
||||||
function renderHouseRules(rules: HouseRules): void {
|
function gameCardSummary(f: Frame): string {
|
||||||
const { passengerPerCoach: pax, freightPerLoad: frt, trainPerTransit: trn } = rules.revenue;
|
const { passengerPerCoach: pax, freightPerLoad: frt, trainPerTransit: trn } = f.houseRules.revenue;
|
||||||
const short = { threeRandom: '3 cards', sixRandom: '6 cards', threeTrackThreeOther: '3+3 cards' };
|
const short = { threeRandom: '3 cards', sixRandom: '6 cards', threeTrackThreeOther: '3+3 cards' };
|
||||||
const el = $('houserules');
|
const config = configFromFrame(f);
|
||||||
el.textContent = `· ${short[rules.startingHand]} · ${pax}/${frt}/${trn}`;
|
const type = gameTypeLabel(presetOf(config, f.players.length, f.days), f.mode);
|
||||||
const handWords = STARTING_HAND_LABELS.find((o) => o.value === rules.startingHand)?.label ?? '';
|
const floor = f.minCombinedRevenue === 0 ? 'no floor' : `floor ${f.minCombinedRevenue}`;
|
||||||
|
return `${type} · ${f.days} Days · ${floor} · ${short[f.houseRules.startingHand]} · ${pax}/${frt}/${trn}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* THIS GAME — every setting it was dealt under, in a card rather than along the top line (TODO #28).
|
||||||
|
*
|
||||||
|
* Jesse, 2026-08-23: "the game-specific information in the very top line should probably be a card
|
||||||
|
* like Facilities, timetable or blocked. Off on the side, we can give complete information about all
|
||||||
|
* the game options and not take up valuable real estate at the top of the screen." And on when it is
|
||||||
|
* read: "To go, 'Oh wait, what did we set that to?' They should be able to look that up, but it does
|
||||||
|
* not need to be at the top every moment."
|
||||||
|
*
|
||||||
|
* NOTHING NEW TRAVELS FOR THIS. `configFromFrame` already turns the Frame's copy of the config back
|
||||||
|
* into a `GameConfig`, and `rulesListHtml` is the renderer the lobby's join preview and seating
|
||||||
|
* screen already draw — so what a player agreed to before the deal and what they can read mid-game
|
||||||
|
* come from ONE implementation and cannot drift. The identity block above it is the half
|
||||||
|
* `rulesListHtml` has no notion of: which seed or seat this is, and what the game is called.
|
||||||
|
*
|
||||||
|
* THE SEED IS SOLITAIRE-ONLY, and that is a redaction rule rather than a layout one: it is never
|
||||||
|
* sent to a remote client at all, because it would leak every future shuffle and roll
|
||||||
|
* (`multiplayer.md` §7). `RemoteSession` has no `.seed()` to call. A seated player gets their seat
|
||||||
|
* instead, which is the thing they actually need to know.
|
||||||
|
*/
|
||||||
|
function renderGameCard(f: Frame): void {
|
||||||
|
const sec = $('gamecard');
|
||||||
|
sec.classList.toggle('folded', !gameCardOpen);
|
||||||
|
$('gamecardsummary').textContent = gameCardSummary(f);
|
||||||
|
|
||||||
|
const btn = $('gamecardtoggle');
|
||||||
|
btn.textContent = gameCardOpen ? 'hide' : 'show';
|
||||||
|
btn.onclick = () => {
|
||||||
|
gameCardOpen = !gameCardOpen;
|
||||||
|
saveSettings({ gameCardOpen });
|
||||||
|
render();
|
||||||
|
};
|
||||||
|
if (!gameCardOpen) {
|
||||||
|
// Folded: the body is display:none anyway, and rebuilding it every frame is work nobody sees.
|
||||||
|
$('gamecardbody').innerHTML = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = configFromFrame(f);
|
||||||
|
const players = f.players.length;
|
||||||
|
const type = presetOf(config, players, f.days);
|
||||||
|
const who = isLocal(session)
|
||||||
|
? `<dt>Seed</dt><dd>${esc(String(session.seed()))}</dd>`
|
||||||
|
: `<dt>Seat</dt><dd>${esc(String(seatLabel(session.seat())))}</dd>`;
|
||||||
|
const code = gameCode === '' ? '' : `<dt>Game code</dt><dd>${esc(gameCode)}</dd>`;
|
||||||
|
$('gamecardbody').innerHTML =
|
||||||
|
`<dl>${who}${code}<dt>Type</dt><dd>${esc(gameTypeLabel(type, f.mode))}</dd></dl>` +
|
||||||
|
rulesListHtml(config, players, f.days);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* THE COLLISION COUNTS, WHICH ARE A LIVE SCORE (TODO #28, Jesse's call 2026-08-30).
|
||||||
|
*
|
||||||
|
* They stay on the top line while the limits themselves move into the card, because the two are
|
||||||
|
* different kinds of thing: `maxCollisionsPerDay` is a setting you agreed to once, and "2 of 3
|
||||||
|
* today" is a number that changes how you play the next Stage. The Frame has carried both counts
|
||||||
|
* since v0.7.0 and nothing drew them, so the one victory condition that ends a game EARLY ran
|
||||||
|
* invisibly — v0.7.9 made it reachable in solitaire too, which is what made this worth having.
|
||||||
|
*
|
||||||
|
* `0` means the limit is off (the engine's convention), and a half that is off is left out rather
|
||||||
|
* than shown as "1 of 0". With both off the chip is empty, and an empty span collapses.
|
||||||
|
*/
|
||||||
|
function renderCollisions(f: Frame): void {
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (f.maxCollisionsPerDay > 0) parts.push(`${f.collisionsToday} of ${f.maxCollisionsPerDay} today`);
|
||||||
|
if (f.maxCollisionsTotal > 0) parts.push(`${f.collisionsTotal} of ${f.maxCollisionsTotal} total`);
|
||||||
|
const el = $('collisions');
|
||||||
|
el.textContent = parts.length === 0 ? '' : `collisions ${parts.join(' · ')}`;
|
||||||
el.title =
|
el.title =
|
||||||
`Opening hand: ${handWords.toLowerCase()}.\n` +
|
parts.length === 0
|
||||||
`Passenger revenue per coach: ${pax} (paid on boarding and again on detraining).\n` +
|
? ''
|
||||||
`Freight revenue per load: ${frt} (paid on loading and again on unloading).\n` +
|
: 'Reaching either limit ends the game immediately and results in a loss. Both limits are in ' +
|
||||||
`Train revenue per transit: ${trn} (paid to every player when a train leaves the Division).`;
|
'the This Game card; these are the running counts.';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -505,7 +585,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,
|
||||||
@@ -532,6 +612,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);
|
||||||
@@ -611,7 +693,7 @@ function noteDayEnd(f: Frame): void {
|
|||||||
* nothing saying this was the game just set up. `#phasenote` cannot help: it announces a CHANGE of
|
* 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;
|
||||||
@@ -620,20 +702,43 @@ 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),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Toggles the two mutually-exclusive top-level screens `play.html` defines — `#lobby` (Phase 4)
|
/**
|
||||||
* and `#gameui` (the board, whether local or remote). Both start `hidden` in the markup so neither
|
* COMING BACK TO A GAME, said out loud — the solitaire counterpart to `noteFirstFrame`.
|
||||||
* ever flashes before `start()` decides which one this load actually needs. */
|
*
|
||||||
function showScreen(which: 'lobby' | 'gameui'): void {
|
* A restored game draws exactly like a dealt one: mid-Day, mid-phase, with a log already several
|
||||||
|
* turns deep. Nothing distinguished "this is the game you left" from "this is a game that has just
|
||||||
|
* started", and the multiplayer path had the opposite problem — it announced that the game had
|
||||||
|
* BEGUN to a player rejoining one (Jesse, 2026-08-30: "do not post a message that says 'The game
|
||||||
|
* has begun.' … it needs to say 'The game has resumed.'").
|
||||||
|
*/
|
||||||
|
function announceResumed(f: Frame): void {
|
||||||
|
flashAnnounce(`The game has resumed — Day ${f.day}, Stage ${f.stage}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Toggles the three mutually-exclusive top-level screens `play.html` defines — `#lobby` (Phase 4),
|
||||||
|
* `#gameui` (the board, whether local or remote), and `#solitairesetup` (asked before the first
|
||||||
|
* solitaire deal, the same way `#lobby` is asked before the first multiplayer one — Jesse,
|
||||||
|
* 2026-08-29). All three start `hidden` in the markup so none ever flashes before `start()` decides
|
||||||
|
* which one this load actually needs. */
|
||||||
|
function showScreen(which: 'lobby' | 'gameui' | 'solitairesetup'): void {
|
||||||
document.getElementById('lobby')!.hidden = which !== 'lobby';
|
document.getElementById('lobby')!.hidden = which !== 'lobby';
|
||||||
document.getElementById('gameui')!.hidden = which !== 'gameui';
|
document.getElementById('gameui')!.hidden = which !== 'gameui';
|
||||||
|
document.getElementById('solitairesetup')!.hidden = which !== 'solitairesetup';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -642,7 +747,7 @@ function showScreen(which: 'lobby' | 'gameui'): 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');
|
||||||
@@ -652,6 +757,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
|
||||||
@@ -714,12 +820,31 @@ function start(): void {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ASKING FOR SOLITAIRE BEATS RESUMING A MULTIPLAYER SESSION TOO — same reasoning as `?lobby`
|
||||||
|
* above, for the door on the other side. A browser that has ever held a multiplayer seat carries
|
||||||
|
* `remembered` forever (`loadRemote` finds it below), and a bare `./play.html` load could not tell
|
||||||
|
* "I clicked Play solitaire" apart from "I reloaded mid-game" — so the splash's solitaire door
|
||||||
|
* always lost to whatever multiplayer game or lobby this browser last touched, and could never
|
||||||
|
* actually reach solitaire. Found 2026-08-29 verifying v0.7.5 on `phoenix.local`: the door landed
|
||||||
|
* back in a Co-op, four-seat LOBBY from unrelated earlier testing rather than solitaire's own new
|
||||||
|
* setup screen. The door now marks its intent explicitly, the same way `?lobby` already does —
|
||||||
|
* and so does everything else that already means "this is a solitaire navigation": an explicit
|
||||||
|
* `?seed=` (a shared or bookmarked deal) and `?hand=` (the setup screen's own Deal button writes
|
||||||
|
* it on every commit, so landing back here with it set is that navigation, not a bare reload).
|
||||||
|
* Checked here, ahead of `remembered`, rather than only below with `saved` — otherwise Deal would
|
||||||
|
* work once and then bounce the very next load into whatever multiplayer game this browser last
|
||||||
|
* touched, since its URL carries `hand=` but not `solitaire=`.
|
||||||
|
*/
|
||||||
|
const wantsSolitaire =
|
||||||
|
params.get('solitaire') !== null || params.get('seed') !== null || params.has('hand');
|
||||||
|
|
||||||
// Entered without checking it still exists — deliberately. Verifying up front would mean an
|
// Entered without checking it still exists — deliberately. Verifying up front would mean an
|
||||||
// await before anything renders on the common path, where the game IS still there; instead the
|
// await before anything renders on the common path, where the game IS still there; instead the
|
||||||
// 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 = 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;
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
@@ -739,23 +864,54 @@ function start(): void {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
showScreen('gameui');
|
// A saved game carries its OWN rules and re-deals itself under them, whatever the URL says — see
|
||||||
|
// `configFor`. Read once, here, so the same answer decides both whether to ask before dealing and
|
||||||
|
// (below) whether to restore.
|
||||||
|
const saved = load();
|
||||||
const requested = params.get('seed');
|
const requested = params.get('seed');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ASK BEFORE THE FIRST DEAL, THE SAME WAY THE LOBBY ASKS BEFORE THE FIRST MULTIPLAYER GAME
|
||||||
|
* (Jesse, 2026-08-29 — "let the user choose their options like the start of a multiplayer game";
|
||||||
|
* "asking first is the only path").
|
||||||
|
*
|
||||||
|
* Three things answer the question and so skip the screen, in this order of precedence:
|
||||||
|
* `hand` (every `commitNewGame` write sets it, so this navigation IS the Deal button landing back
|
||||||
|
* here to deal), `seed` (a specific deal someone chose to share or bookmark), and — only when the
|
||||||
|
* player did not explicitly ask to set one up — an existing save, which is a game to resume.
|
||||||
|
*
|
||||||
|
* THE DOOR OUTRANKS A SAVED GAME, and getting that wrong is what made this feature unreachable
|
||||||
|
* for three releases. v0.7.5 skipped the screen whenever `load()` found ANYTHING, reasoned as "a
|
||||||
|
* saved game is a game to resume" — but a browser that has ever played solitaire always has one,
|
||||||
|
* so the door could never reach the screen again. Reported three times (Jesse, 2026-08-29 twice
|
||||||
|
* and 2026-08-30); a private window appeared to absolve it only because it had never played and
|
||||||
|
* so had no save. Clicking "Play solitaire" is a request to set a game up, not to resume one — a
|
||||||
|
* BARE reload is the resume case, and still is. `#ss-resume` is what keeps the save reachable, so
|
||||||
|
* this costs nobody the game they were playing.
|
||||||
|
*/
|
||||||
|
const askedToSetUp = params.get('solitaire') !== null;
|
||||||
|
if (requested === null && !params.has('hand') && (askedToSetUp || !saved)) {
|
||||||
|
showScreen('solitairesetup');
|
||||||
|
runSolitaireSetup(params, saved !== null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
showScreen('gameui');
|
||||||
// A seed in the URL makes a game shareable and reproducible: same link, same deal.
|
// A seed in the URL makes a game shareable and reproducible: same link, same deal.
|
||||||
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;
|
||||||
|
const restored = Boolean(saved) && requested === null;
|
||||||
// A saved game carries its OWN rules and re-deals itself under them, whatever the URL says — see
|
if (saved && restored) local.restore(saved);
|
||||||
// `configFor`. That is why the restore happens after the session is built rather than feeding it.
|
|
||||||
const saved = load();
|
|
||||||
if (saved && requested === null) 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());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -853,21 +1009,32 @@ function renderPresence(f: Frame): void {
|
|||||||
*/
|
*/
|
||||||
function renderGameIdentity(f: Frame): void {
|
function renderGameIdentity(f: Frame): void {
|
||||||
const codeEl = document.getElementById('gamecode');
|
const codeEl = document.getElementById('gamecode');
|
||||||
if (codeEl) codeEl.textContent = gameCode === '' ? '' : `game ${gameCode}`;
|
if (!codeEl) return;
|
||||||
|
codeEl.textContent = gameCode === '' ? '' : `game ${gameCode}`;
|
||||||
const el = document.getElementById('gametype');
|
/**
|
||||||
if (!el) return;
|
* THE CODE KEEPS ITS TOOLTIP, AND THE TOOLTIP KEEPS THE RULES. The game type and the house rules
|
||||||
|
* moved into the This Game card (TODO #28), but the code is the thing a player reads out to say
|
||||||
|
* WHICH game they are in — so it is worth being able to hover it and get the whole answer without
|
||||||
|
* opening the card.
|
||||||
|
*
|
||||||
|
* IN SOLITAIRE THERE IS NO CODE, so the span is empty and this tooltip is unreachable. That is not
|
||||||
|
* a hole: the card's summary line is always on screen whether the card is folded or not, and it
|
||||||
|
* opens with the type — "Solitaire · 5 Days · floor 15 · 3 cards · 4/2/1". A lone player has no
|
||||||
|
* game to name to anybody, and the one thing this tooltip adds over that line is the blurb.
|
||||||
|
*/
|
||||||
const config = configFromFrame(f);
|
const config = configFromFrame(f);
|
||||||
const players = f.players.length;
|
const players = f.players.length;
|
||||||
const type = presetOf(config, players, f.days);
|
const type = presetOf(config, players, f.days);
|
||||||
const near = closestPreset(config, players, f.days);
|
const near = closestPreset(config, players, f.days);
|
||||||
el.textContent = gameTypeLabel(type, f.mode);
|
const blurb =
|
||||||
el.title =
|
|
||||||
type === 'custom'
|
type === 'custom'
|
||||||
? `A custom game, scored as ${preset(near.name).scoring === 'coop' ? 'Co-op' : 'Competitive'}. ` +
|
? `A custom game, scored as ${preset(near.name).scoring === 'coop' ? 'Co-op' : 'Competitive'}. ` +
|
||||||
`${near.differing.length} ${near.differing.length === 1 ? 'setting differs' : 'settings differ'} ` +
|
`${near.differing.length} ${near.differing.length === 1 ? 'setting differs' : 'settings differ'} ` +
|
||||||
`from ${preset(near.name).label}.\n\n${rulesSummary(f)}`
|
`from ${preset(near.name).label}.`
|
||||||
: `${preset(type).blurb}\n\n${rulesSummary(f)}`;
|
: preset(type).blurb;
|
||||||
|
codeEl.title =
|
||||||
|
`${gameTypeLabel(type, f.mode)}. ${blurb}\n\n${rulesSummary(f)}\n\n` +
|
||||||
|
'The full settings are in the This Game card, at the foot of the right-hand column.';
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The victory conditions in force, spelled out for the header's tooltip. */
|
/** The victory conditions in force, spelled out for the header's tooltip. */
|
||||||
@@ -884,7 +1051,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.
|
||||||
@@ -927,11 +1094,9 @@ function render(): void {
|
|||||||
? `${f.revenue} · Day ${f.day} — ${f.extraDays} beyond the timetable`
|
? `${f.revenue} · Day ${f.day} — ${f.extraDays} beyond the timetable`
|
||||||
: `${f.revenue} of ${f.objective.target} · ${left}`;
|
: `${f.revenue} of ${f.objective.target} · ${left}`;
|
||||||
obj.className = 'pace';
|
obj.className = 'pace';
|
||||||
// The seed is never sent to a remote client at all (it would leak every future shuffle and roll,
|
renderCollisions(f);
|
||||||
// `multiplayer.md` §7) — `RemoteSession` has no `.seed()` because there is nothing to return.
|
|
||||||
$('seed').textContent = isLocal(session) ? String(session.seed()) : `Seat ${seatLabel(session.seat())}`;
|
|
||||||
renderGameIdentity(f);
|
renderGameIdentity(f);
|
||||||
renderHouseRules(f.houseRules);
|
renderGameCard(f);
|
||||||
|
|
||||||
// -- division
|
// -- division
|
||||||
$('division').innerHTML = divisionSvg(f.division, {
|
$('division').innerHTML = divisionSvg(f.division, {
|
||||||
@@ -1211,15 +1376,40 @@ function render(): void {
|
|||||||
* WHERE THE GAME BEGAN. In a multiplayer game the bots move the instant the host presses Start, so
|
* WHERE THE GAME BEGAN. In a multiplayer game the bots move the instant the host presses Start, so
|
||||||
* by the time the board paints the log already has several turns in it and nothing says which of
|
* by the time the board paints the log already has several turns in it and nothing says which of
|
||||||
* them are yours to have missed. Only drawn while the whole log is on screen: past sixty lines the
|
* them are yours to have missed. Only drawn while the whole log is on screen: past sixty lines the
|
||||||
* top of the panel is no longer the start of the game, and a marker claiming otherwise would lie.
|
* end of the panel is no longer the start of the game, and a marker claiming otherwise would lie.
|
||||||
*/
|
*/
|
||||||
const startMarker =
|
const startMarker =
|
||||||
!isLocal(session) && allLines.length === shownLines.length
|
!isLocal(session) && allLines.length === shownLines.length
|
||||||
? '<div class="line t-phase">— the game began —</div>'
|
? '<div class="line t-phase">— the game began —</div>'
|
||||||
: '';
|
: '';
|
||||||
|
/**
|
||||||
|
* NEWEST FIRST (TODO #23). Jesse, 2026-08-30: "it should be reversed so the top line is the most
|
||||||
|
* recent and the further down you go, the older the entry."
|
||||||
|
*
|
||||||
|
* The panel used to run oldest-first and scroll itself to the bottom, so the thing that had just
|
||||||
|
* happened was the one line you had to go and find. A glance at the top is now always the most
|
||||||
|
* recent thing, and `scrollTop = 0` keeps it there as lines arrive rather than chasing the end.
|
||||||
|
*
|
||||||
|
* THE PHASE HEADINGS NOW TRAIL THEIR LINES, and that is accepted rather than overlooked. A
|
||||||
|
* `t-phase` line reads forwards — it introduces what follows it — so reversing puts each one
|
||||||
|
* BELOW the events it announced. Jesse ruled on it directly: "stage changes will be beneath
|
||||||
|
* (prior to / older than) the following events. That is OK." Reading down the panel is reading
|
||||||
|
* backwards in time, and a heading sitting under its own lines is what backwards looks like.
|
||||||
|
* Grouping by phase and reversing the groups was the alternative, and it was declined as more
|
||||||
|
* machinery than the complaint needs.
|
||||||
|
*
|
||||||
|
* The start marker moves with the same logic: it is the OLDEST thing on screen, so it goes last.
|
||||||
|
*
|
||||||
|
* `replays.ts` keeps its own oldest-first log deliberately — it is paired with a frame stepper,
|
||||||
|
* where "what just happened" is the step you have this moment clicked, so newest-first would
|
||||||
|
* fight the stepping rather than help it.
|
||||||
|
*/
|
||||||
log.innerHTML =
|
log.innerHTML =
|
||||||
startMarker + shownLines.map((l) => `<div class="line t-${l.tone}">${esc(l.text)}</div>`).join('');
|
shownLines
|
||||||
log.scrollTop = log.scrollHeight;
|
.map((l) => `<div class="line t-${l.tone}">${esc(l.text)}</div>`)
|
||||||
|
.reverse()
|
||||||
|
.join('') + startMarker;
|
||||||
|
log.scrollTop = 0;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SAY WHEN THE PHASE TURNS OVER.
|
* SAY WHEN THE PHASE TURNS OVER.
|
||||||
@@ -1331,21 +1521,35 @@ function renderDistrict(f: Frame): void {
|
|||||||
`${f.cells.length} cards · ${f.facilities.length} facilities · ${cars} cars standing` +
|
`${f.cells.length} cards · ${f.facilities.length} facilities · ${cars} cars standing` +
|
||||||
(crew > 0 ? ` · ${crew} crew on the board` : '');
|
(crew > 0 ? ` · ${crew} crew on the board` : '');
|
||||||
|
|
||||||
// Say what pressing it DOES, not what the panel is currently doing. "auto · folded" reads as a
|
/**
|
||||||
// status line and was missed entirely; "always show" is an instruction.
|
* THREE CONTROLS, ONE PER MODE (TODO #16) — not one control that cycles.
|
||||||
const btn = $('districttoggle');
|
*
|
||||||
btn.textContent =
|
* The cycle was `auto -> (open ? 'closed' : 'open') -> auto`, where `open` is what auto is doing
|
||||||
districtMode === 'auto'
|
* AT THAT MOMENT — `FOCUS_PHASES.has(f.phaseKey)`. So which pin a press reached depended on the
|
||||||
? (open ? 'auto-hide: on — click to keep open' : 'auto-hide: on — click to show')
|
* phase: during Local Operations or Cargo it offered "always hidden", and in every other phase
|
||||||
: districtMode === 'open'
|
* "always showing". Getting from one pin to the other meant clicking back to auto, waiting for
|
||||||
? 'always showing — click for auto-hide'
|
* the phase to turn over, and clicking again — which is why it never read as a setting.
|
||||||
: 'always hidden — click for auto-hide';
|
*
|
||||||
btn.onclick = () => {
|
* The labels still say what pressing DOES rather than what the panel is doing. That was a
|
||||||
// auto -> pin it to the opposite of what auto is doing -> back to auto.
|
* deliberate earlier fix ("auto · folded" read as a status line and was missed entirely) and it
|
||||||
districtMode = districtMode === 'auto' ? (open ? 'closed' : 'open') : 'auto';
|
* survives the change; what the CYCLE could not do was be honest about the state it was in, which
|
||||||
|
* is now carried by `aria-pressed` and the lit button instead of by the label.
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* Addressed by id, one lookup each, rather than by querying the container's children. Everything
|
||||||
|
* else on this page is reached with `$('...')`, and it is what makes the control testable at all:
|
||||||
|
* the page never writes this markup, so a child query finds nothing in a stubbed DOM and the
|
||||||
|
* whole control would ship green and unexercised.
|
||||||
|
*/
|
||||||
|
for (const mode of ['auto', 'open', 'closed'] as const) {
|
||||||
|
const b = $(`dm-${mode}`);
|
||||||
|
b.setAttribute('aria-pressed', String(mode === districtMode));
|
||||||
|
b.onclick = () => {
|
||||||
|
districtMode = mode;
|
||||||
saveSettings({ districtMode });
|
saveSettings({ districtMode });
|
||||||
render();
|
render();
|
||||||
};
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1504,6 +1708,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();
|
||||||
@@ -1895,155 +2126,142 @@ if (leaveBtn) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const newBtn = document.getElementById('newgame');
|
/**
|
||||||
const dlg = document.getElementById('newgamedlg') as HTMLDialogElement | null;
|
* ONE GAME-TYPE BLOCK, WIRED — the type radios, the shared rules form beneath them, and the small
|
||||||
if (newBtn && dlg) {
|
* glue between them (which type is currently selected, what its note says, how Days feeds the
|
||||||
const field = <T extends HTMLElement>(id: string): T => document.getElementById(id) as T;
|
* floor). The in-game "New game" dialog (`ng-`) and the pre-game setup screen (`ss-`, Gitea
|
||||||
const ngForm = settingsForm('ng-');
|
* "let the user choose their options like the start of a multiplayer game", 2026-08-29) both need
|
||||||
|
* an identical copy of this — factored out once so the two cannot drift apart the way the rules
|
||||||
/**
|
* block itself already had before `settings-form.ts` existed to stop it.
|
||||||
* 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
|
* PREFILLING IS DELIBERATELY LEFT TO THE CALLER. The dialog opens on the game CURRENTLY IN PLAY
|
||||||
* is how it ended up with "where an Extra may start" that the lobby did not have and none of the
|
* (so redealing to compare keeps comparing); the setup screen opens on the plain Solitaire
|
||||||
* three optional rules that it did. Both screens now read `presets.ts` and drive their block
|
* defaults, because there is no game yet to read. `setBase` plus a direct `form.write(...)` is the
|
||||||
* through `settings-form.ts`; only Solitaire can actually be DEALT here, so the three multiplayer
|
* seam that lets each caller do its own version of "what do these fields show at first paint"
|
||||||
* types are shown disabled rather than hidden — what this screen offers and what the lobby offers
|
* without this function having to guess which one it is wiring.
|
||||||
* should read as one list, not two.
|
|
||||||
*/
|
*/
|
||||||
let ngBase: PresetName = 'solitaire';
|
type WiredGameType = {
|
||||||
let ngType: GameType = 'solitaire';
|
form: SettingsForm;
|
||||||
/** As in the lobby: the floor is derived from the length until the player sets one themselves. */
|
days(): number;
|
||||||
let ngFloorTyped = false;
|
refresh(): void;
|
||||||
|
/** The common case: prefill straight from a named type's own defaults, then repaint. */
|
||||||
|
selectPreset(name: PresetName): void;
|
||||||
|
/** The dialog's case: the caller writes the form itself (from a live game), then calls `refresh`
|
||||||
|
* — this only sets which type that write should be compared against. */
|
||||||
|
setBase(name: PresetName, type: GameType): void;
|
||||||
|
};
|
||||||
|
|
||||||
const ngDays = (): number => {
|
function wireGameTypeBlock(prefix: string, root: ParentNode): WiredGameType {
|
||||||
const raw = Number(field<HTMLInputElement>('ng-days').value);
|
const field = <T extends HTMLElement>(id: string): T => document.getElementById(`${prefix}${id}`) as T;
|
||||||
|
const form = settingsForm(prefix);
|
||||||
|
|
||||||
|
let base: PresetName = 'solitaire';
|
||||||
|
let type: GameType = 'solitaire';
|
||||||
|
/** As in the lobby: the floor is derived from the length until the player sets one themselves. */
|
||||||
|
let floorTyped = false;
|
||||||
|
|
||||||
|
const days = (): number => {
|
||||||
|
const raw = Number(field<HTMLInputElement>('days').value);
|
||||||
return Number.isFinite(raw) && raw >= 1 ? Math.round(raw) : 5;
|
return Number.isFinite(raw) && raw >= 1 ? Math.round(raw) : 5;
|
||||||
};
|
};
|
||||||
|
|
||||||
const ngTypeRadios = (): HTMLInputElement[] =>
|
const typeRadios = (): HTMLInputElement[] =>
|
||||||
Array.from(dlg.querySelectorAll<HTMLInputElement>('input[name="ng-type"]'));
|
Array.from(root.querySelectorAll<HTMLInputElement>(`input[name="${prefix}type"]`));
|
||||||
|
|
||||||
function ngRefresh(): void {
|
function refresh(): void {
|
||||||
const differing = ngForm.mark(ngBase, 1, ngDays());
|
const differing = form.mark(base, 1, days());
|
||||||
if (differing.length > 0) ngType = 'custom';
|
if (differing.length > 0) type = 'custom';
|
||||||
else if (ngType === 'custom') ngType = ngBase;
|
else if (type === 'custom') type = base;
|
||||||
for (const r of ngTypeRadios()) r.checked = r.value === ngType;
|
for (const r of typeRadios()) r.checked = r.value === type;
|
||||||
const note = field<HTMLElement>('ng-type-note');
|
/**
|
||||||
note.textContent =
|
* NO SENTENCE UNDER THE RADIOS. It restated the type just chosen — the row is already labelled
|
||||||
ngType === 'custom'
|
* and already carries its own one-line description — so it was the choice read back to the
|
||||||
? `${gameTypeLabel('custom', preset(ngBase).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(ngBase).label}.`
|
* what they're playing. There's no need to repeat it below.").
|
||||||
: preset(ngType 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 ngSelectPreset(name: PresetName): void {
|
function selectPreset(name: PresetName): void {
|
||||||
ngBase = name;
|
base = name;
|
||||||
ngType = name;
|
type = name;
|
||||||
ngFloorTyped = false;
|
floorTyped = false;
|
||||||
const values = presetSettings(name, 1, ngDays());
|
const values = presetSettings(name, 1, days());
|
||||||
ngForm.write(values, values);
|
form.write(values, values);
|
||||||
ngRefresh();
|
refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const r of ngTypeRadios()) {
|
function setBase(name: PresetName, t: GameType): void {
|
||||||
// Nothing here can deal a multiplayer game: a `LocalSession` runs the engine in this browser and
|
base = name;
|
||||||
// a table needs a server. The lobby is the door, and the row says so rather than just refusing
|
type = t;
|
||||||
// the click (Jesse, 2026-08-23 — a disabled radio that looks enabled reads as a broken one).
|
floorTyped = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
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. 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;
|
||||||
if (r.value === 'custom') {
|
if (r.value === 'custom') {
|
||||||
ngType = 'custom';
|
type = 'custom';
|
||||||
ngRefresh();
|
refresh();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
ngSelectPreset(r.value as PresetName);
|
selectPreset(r.value as PresetName);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
ngForm.onEdit((key) => {
|
form.onEdit((key) => {
|
||||||
if (key === 'minCombinedRevenue') ngFloorTyped = true;
|
if (key === 'minCombinedRevenue') floorTyped = true;
|
||||||
ngType = 'custom';
|
type = 'custom';
|
||||||
ngRefresh();
|
refresh();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Days is a parameter, not a rule: it re-derives the floor and never makes a game Custom by itself.
|
// Days is a parameter, not a rule: it re-derives the floor and never makes a game Custom by itself.
|
||||||
field<HTMLInputElement>('ng-days').oninput = () => {
|
field<HTMLInputElement>('days').oninput = () => {
|
||||||
if (!ngFloorTyped) {
|
if (!floorTyped) {
|
||||||
const values = ngForm.read();
|
const values = form.read();
|
||||||
const want = presetSettings(ngBase, 1, ngDays());
|
const want = presetSettings(base, 1, days());
|
||||||
ngForm.write({ ...values, minCombinedRevenue: want.minCombinedRevenue }, want);
|
form.write({ ...values, minCombinedRevenue: want.minCombinedRevenue }, want);
|
||||||
}
|
}
|
||||||
ngRefresh();
|
refresh();
|
||||||
};
|
};
|
||||||
|
|
||||||
// "Everyone moves one chair left" has no meaning at a table of one — disabled with the rest of the
|
// "Everyone moves one chair left" has no meaning at a table of one — disabled with the rest of the
|
||||||
// block still visible, so the two screens read the same.
|
// block still visible, so every screen that offers it reads the same.
|
||||||
ngForm.setEmployeeRotationAvailable(false);
|
form.setEmployeeRotationAvailable(false);
|
||||||
|
|
||||||
/**
|
return { form, days, refresh, selectPreset, setBase };
|
||||||
* 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 COMMIT — reads a wired block's answers and turns them into a URL, the same path `?seed=`
|
||||||
* the open question this game has (`TODO.md`). A dialog is what lets a playtest be a playtest.
|
* already took: `start()` reads it back out, so there is exactly one place that turns a URL into a
|
||||||
*
|
* game, whichever screen produced it.
|
||||||
* 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 = () => {
|
function commitNewGame(wired: WiredGameType, seedFieldValue: string): void {
|
||||||
// The button itself is hidden for a session that cannot deal (`applyCapabilities`), but the
|
const asked = seedFieldValue.trim();
|
||||||
// dialog's whole answer-reading/URL-navigating flow below assumes a LocalSession throughout, so
|
// A seed the browser cannot parse is not a reason to refuse to deal — blank and unparseable both
|
||||||
// the guard is repeated — and `local` is captured as a `const` so the narrowing survives the
|
// mean "surprise me", which is what leaving the box alone plainly asks for.
|
||||||
// closures below it (see `renderUndo`'s identical note on why `session` itself cannot be).
|
|
||||||
if (!isLocal(session)) return;
|
|
||||||
const local = session;
|
|
||||||
const f = local.view();
|
|
||||||
const day = f.day;
|
|
||||||
const started = f.status === 'active' && (day > 1 || f.stage > 1);
|
|
||||||
if (started && !confirm(`Forget this game (seed ${local.seed()}, Day ${day}) and deal a new one?`)) return;
|
|
||||||
|
|
||||||
field<HTMLInputElement>('ng-seed').value = '';
|
|
||||||
field<HTMLInputElement>('ng-days').value = String(f.days);
|
|
||||||
ngBase = 'solitaire';
|
|
||||||
ngType = 'solitaire';
|
|
||||||
ngFloorTyped = false;
|
|
||||||
// The rules actually in play, then the comparison decides what to call them.
|
|
||||||
ngForm.write(settingsOf(configFromFrame(f)), presetSettings('solitaire', 1, f.days));
|
|
||||||
ngForm.setEmployeeRotationAvailable(false);
|
|
||||||
ngRefresh();
|
|
||||||
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.
|
|
||||||
*
|
|
||||||
* The answers go into the URL and the page navigates, which is the same path `?seed=` already
|
|
||||||
* took: `start()` reads them back, so there is exactly one place that turns a URL into a game.
|
|
||||||
*/
|
|
||||||
dlg.addEventListener('close', () => {
|
|
||||||
if (dlg.returnValue !== 'deal') return;
|
|
||||||
|
|
||||||
const asked = field<HTMLInputElement>('ng-seed').value.trim();
|
|
||||||
// A seed the browser cannot parse is not a reason to refuse to deal — blank and unparseable
|
|
||||||
// both mean "surprise me", which is what leaving the box alone plainly asks for.
|
|
||||||
const seed = asked === '' || !Number.isFinite(Number(asked)) ? '' : String(Math.trunc(Number(asked)));
|
const seed = asked === '' || !Number.isFinite(Number(asked)) ? '' : String(Math.trunc(Number(asked)));
|
||||||
const settings = ngForm.read();
|
const settings = wired.form.read();
|
||||||
const rules = houseRules({
|
const rules = houseRules({
|
||||||
houseRules: {
|
houseRules: {
|
||||||
startingHand: settings.startingHand,
|
startingHand: settings.startingHand,
|
||||||
@@ -2057,7 +2275,7 @@ if (newBtn && dlg) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
const victory: NewGameOptions = {
|
const victory: NewGameOptions = {
|
||||||
days: Math.max(1, ngDays()),
|
days: Math.max(1, wired.days()),
|
||||||
minCombinedRevenue: settings.minCombinedRevenue,
|
minCombinedRevenue: settings.minCombinedRevenue,
|
||||||
maxCollisionsPerDay: settings.maxCollisionsPerDay,
|
maxCollisionsPerDay: settings.maxCollisionsPerDay,
|
||||||
maxCollisionsTotal: settings.maxCollisionsTotal,
|
maxCollisionsTotal: settings.maxCollisionsTotal,
|
||||||
@@ -2077,7 +2295,96 @@ if (newBtn && dlg) {
|
|||||||
// deal another at the same settings. Reload instead, and `start()` rolls a fresh seed.
|
// deal another at the same settings. Reload instead, and `start()` rolls a fresh seed.
|
||||||
if (next === location.search) location.reload();
|
if (next === location.search) location.reload();
|
||||||
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');
|
||||||
|
if (newBtn) {
|
||||||
|
newBtn.onclick = () => {
|
||||||
|
showScreen('solitairesetup');
|
||||||
|
// IN PLACE, not a navigation: the live session stays in memory, so the fields can open on the
|
||||||
|
// rules actually being played and "Continue saved game" is just showing the board
|
||||||
|
// again rather than a reload and a replay.
|
||||||
|
runSolitaireSetup(new URLSearchParams(), true, session.view());
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* THE PRE-GAME SETUP SCREEN — asked before the FIRST solitaire deal, the same way `#lobby` is
|
||||||
|
* already asked before the first multiplayer one (Jesse, 2026-08-29: "let the user choose their
|
||||||
|
* options like the start of a multiplayer game"; "asking first is the only path").
|
||||||
|
*
|
||||||
|
* Only reached for a genuinely fresh visit — `start()` is what decides that; by the time this runs,
|
||||||
|
* there is no saved game and no URL already carrying a deal's answers. It opens on the plain
|
||||||
|
* Solitaire defaults, since there is no live game to compare against yet, and reuses the identical
|
||||||
|
* `wireGameTypeBlock`/`commitNewGame` pair the in-game dialog uses — the two are one design, not two.
|
||||||
|
*/
|
||||||
|
function runSolitaireSetup(params: URLSearchParams, hasSave = false, live: Frame | null = null): void {
|
||||||
|
const screen = document.getElementById('solitairesetup');
|
||||||
|
const dealBtn = document.getElementById('ss-deal');
|
||||||
|
if (!screen || !dealBtn) return;
|
||||||
|
|
||||||
|
const ss = wireGameTypeBlock('ss-', screen);
|
||||||
|
// A `?seed=` with no other rules params still means SOMETHING — a shared or bookmarked link
|
||||||
|
// naming a specific deal — so it is honoured as a prefill rather than discarded because this
|
||||||
|
// visit happened to be routed through the screen that now asks first.
|
||||||
|
const seedField = document.getElementById('ss-seed') as HTMLInputElement | null;
|
||||||
|
if (seedField) seedField.value = params.get('seed') ?? '';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WHAT THE FIELDS OPEN ON, and it is not the same question in both directions.
|
||||||
|
*
|
||||||
|
* Reached mid-game from "New game", this opens on the rules CURRENTLY IN PLAY — that is what the
|
||||||
|
* deleted dialog was good for, and losing it would make "change one dial and redeal to compare"
|
||||||
|
* impossible. Reached from the splash, there is no game to read, so it opens on the plain
|
||||||
|
* Solitaire defaults.
|
||||||
|
*/
|
||||||
|
if (live) {
|
||||||
|
const daysField = document.getElementById('ss-days') as HTMLInputElement | null;
|
||||||
|
if (daysField) daysField.value = String(live.days);
|
||||||
|
ss.setBase('solitaire', 'solitaire');
|
||||||
|
ss.form.write(settingsOf(configFromFrame(live)), presetSettings('solitaire', 1, live.days));
|
||||||
|
ss.refresh();
|
||||||
|
} else {
|
||||||
|
ss.selectPreset('solitaire');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* THE WAY BACK TO A GAME IN PROGRESS, and the reason the door is allowed to outrank a save at all.
|
||||||
|
* Dealing from here calls `clearSave()`, so a player who reached this screen from the splash — by
|
||||||
|
* clicking "Play solitaire", which nobody reads as "throw away what I was playing" — needs their
|
||||||
|
* game one button away and needs to be told what Deal costs.
|
||||||
|
*
|
||||||
|
* Mid-game the game is still in memory, so going back is just showing it again. From the splash
|
||||||
|
* there is nothing loaded yet, so it is a navigation to the bare URL and `start()` restores the
|
||||||
|
* save — one place that turns a URL into a game, either way.
|
||||||
|
*/
|
||||||
|
const resumeBtn = document.getElementById('ss-resume');
|
||||||
|
const savedNote = document.getElementById('ss-saved-note');
|
||||||
|
const canResume = hasSave || live !== null;
|
||||||
|
if (resumeBtn) {
|
||||||
|
resumeBtn.hidden = !canResume;
|
||||||
|
resumeBtn.onclick = live
|
||||||
|
? () => {
|
||||||
|
showScreen('gameui');
|
||||||
|
render();
|
||||||
|
announceResumed(session.view());
|
||||||
|
}
|
||||||
|
: () => void (location.search = '');
|
||||||
|
}
|
||||||
|
if (savedNote) savedNote.hidden = !canResume;
|
||||||
|
|
||||||
|
dealBtn.onclick = () => commitNewGame(ss, seedField?.value ?? '');
|
||||||
}
|
}
|
||||||
|
|
||||||
const zoomOutBtn = document.getElementById('zoomout') as HTMLButtonElement | null;
|
const zoomOutBtn = document.getElementById('zoomout') as HTMLButtonElement | null;
|
||||||
|
|||||||
+20
-1
@@ -254,8 +254,13 @@ function collisionsHtml(f: Frame): string {
|
|||||||
* game read the words `GAME OVER — revenueFloor`: an internal enum value, printed at the one moment
|
* 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':
|
||||||
@@ -347,7 +352,17 @@ function tallyHtml(t: Frame['tally']): string {
|
|||||||
};
|
};
|
||||||
push('Loads made up', t.loadsCompleted);
|
push('Loads made up', t.loadsCompleted);
|
||||||
push('Loads broken', t.unloadsCompleted);
|
push('Loads broken', t.unloadsCompleted);
|
||||||
|
/**
|
||||||
|
* BOTH HALVES OF THE MEN | AT | WORK PIPELINE, not just the loading one.
|
||||||
|
*
|
||||||
|
* §9.1 makes loading and unloading the same shape — begun, then carried through — and the Tally
|
||||||
|
* has counted both since it was written. The screen reported only the loading side, so a player
|
||||||
|
* with three unloads part-finished at the final whistle was told nothing about them while the
|
||||||
|
* equivalent loads were listed. Found 2026-08-30 auditing which Frame fields nothing reads:
|
||||||
|
* `unloadsBegun` was one of four, and the only one whose absence was visible on screen.
|
||||||
|
*/
|
||||||
push('Loads still in the pipeline', t.loadsStarted - t.loadsCompleted);
|
push('Loads still in the pipeline', t.loadsStarted - t.loadsCompleted);
|
||||||
|
push('Unloads still in the pipeline', t.unloadsBegun - t.unloadsCompleted);
|
||||||
push('Passengers boarded', t.passengersBoarded);
|
push('Passengers boarded', t.passengersBoarded);
|
||||||
push('Passengers detrained', t.passengersDetrained);
|
push('Passengers detrained', t.passengersDetrained);
|
||||||
push('Cars coupled', t.carsCoupled);
|
push('Cars coupled', t.carsCoupled);
|
||||||
@@ -381,6 +396,10 @@ function tallyHtml(t: Frame['tally']): string {
|
|||||||
}
|
}
|
||||||
push('Cards drawn', t.cardsDrawn);
|
push('Cards drawn', t.cardsDrawn);
|
||||||
push('Cards played', t.cardsPlayed);
|
push('Cards played', t.cardsPlayed);
|
||||||
|
// Gitea#9 made throwing a Timetabled train away a legal and deliberate move, so a discard is a
|
||||||
|
// CHOICE the player made rather than an accident of the hand limit — and the engine has counted
|
||||||
|
// it all along while the screen listed only draws and plays beside it.
|
||||||
|
push('Cards discarded', t.cardsDiscarded);
|
||||||
|
|
||||||
return `<h4 class="res-h">The railroad</h4>${factTable(rows)}`;
|
return `<h4 class="res-h">The railroad</h4>${factTable(rows)}`;
|
||||||
}
|
}
|
||||||
|
|||||||
+297
-204
@@ -76,11 +76,24 @@ 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;
|
||||||
padding:10px 12px;margin-bottom:12px}
|
padding:10px 12px;margin-bottom:12px}
|
||||||
#lobby{max-width:1040px;margin:0 auto;padding:14px}
|
#lobby,#solitairesetup{max-width:1040px;margin:0 auto;padding:14px}
|
||||||
/* The create form is two short lists, not one long one: what game this is on the left, what its
|
/* The create form is two short lists, not one long one: what game this is on the left, what its
|
||||||
rules are on the right. Collapses to one column where there is no room for two. */
|
rules are on the right. Collapses to one column where there is no room for two. */
|
||||||
.lb-two{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1.1fr);gap:22px;align-items:start}
|
.lb-two{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1.1fr);gap:22px;align-items:start}
|
||||||
@@ -95,9 +108,8 @@ 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{margin-top:0}
|
#lobby h3,#solitairesetup h3{margin-bottom:2px}
|
||||||
#lobby 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)}
|
||||||
.lb-seat:last-child{border-bottom:none}
|
.lb-seat:last-child{border-bottom:none}
|
||||||
.lb-seat .who{flex:1}
|
.lb-seat .who{flex:1}
|
||||||
@@ -203,6 +215,17 @@ button.act{display:inline-block}
|
|||||||
button.ghost{background:#222831;border:1px solid #4a5361;color:#c6ccd6;font-size:11px;
|
button.ghost{background:#222831;border:1px solid #4a5361;color:#c6ccd6;font-size:11px;
|
||||||
padding:2px 9px;margin-left:10px;text-transform:none;letter-spacing:0;vertical-align:middle}
|
padding:2px 9px;margin-left:10px;text-transform:none;letter-spacing:0;vertical-align:middle}
|
||||||
button.ghost:hover{border-color:#4d6fa8;color:var(--fg)}
|
button.ghost:hover{border-color:#4d6fa8;color:var(--fg)}
|
||||||
|
/* A SEGMENTED CONTROL, BECAUSE A CYCLE COULD NOT REACH EVERY STATE (TODO #16).
|
||||||
|
One button that steps auto -> pinned -> auto can only ever offer the pin OPPOSITE to whatever
|
||||||
|
auto is doing at that moment, which depends on the phase — so "always hidden" was unreachable
|
||||||
|
from "always showing" without waiting for the right phase in between. Three controls, one per
|
||||||
|
mode, and the current one is lit. The buttons still say what they DO rather than what the panel
|
||||||
|
is doing, which was the earlier fix and is worth keeping. */
|
||||||
|
.seg{display:inline-flex;margin-left:10px;vertical-align:middle;border-radius:4px;overflow:hidden;
|
||||||
|
border:1px solid #4a5361}
|
||||||
|
.seg button.ghost{margin:0;border:0;border-radius:0;border-left:1px solid #4a5361}
|
||||||
|
.seg button.ghost:first-child{border-left:0}
|
||||||
|
.seg button.ghost[aria-pressed="true"]{background:#2f3a4a;color:var(--fg);font-weight:600}
|
||||||
#district.folded #grid{display:none}
|
#district.folded #grid{display:none}
|
||||||
#district.folded .districtrule{display:none}
|
#district.folded .districtrule{display:none}
|
||||||
/* Said once, quietly, beside the thing it governs — a rule a player needs on their first district
|
/* Said once, quietly, beside the thing it governs — a rule a player needs on their first district
|
||||||
@@ -212,6 +235,16 @@ button.ghost:hover{border-color:#4d6fa8;color:var(--fg)}
|
|||||||
.districtrule b{color:#cfd6e0}
|
.districtrule b{color:#cfd6e0}
|
||||||
#district.folded #districtsummary{display:block;padding:2px 0 1px;font-size:12px}
|
#district.folded #districtsummary{display:block;padding:2px 0 1px;font-size:12px}
|
||||||
#districtsummary{display:none}
|
#districtsummary{display:none}
|
||||||
|
/* The This Game card folds the same way the district does, and for the same reason: a panel that
|
||||||
|
vanishes entirely reads as broken, so the summary line is what a folded card still says. */
|
||||||
|
#gamecard.folded #gamecardbody{display:none}
|
||||||
|
#gamecard #gamecardsummary{display:none}
|
||||||
|
#gamecard.folded #gamecardsummary{display:block;padding:2px 0 1px;font-size:12px}
|
||||||
|
#gamecardbody dl{display:grid;grid-template-columns:auto 1fr;gap:2px 10px;margin:4px 0 10px}
|
||||||
|
#gamecardbody dt{color:#8b94a3;font-size:11px}
|
||||||
|
#gamecardbody dd{margin:0;font-size:12px;color:#cfd6e0}
|
||||||
|
#gamecardbody dd.changed{color:#f0b64a}
|
||||||
|
#gamecardbody h4{margin:8px 0 0;font-size:11px;text-transform:uppercase;letter-spacing:.06em;color:#8b94a3}
|
||||||
/* An action you cannot take yet keeps its place but drops its light — the amber means "press me",
|
/* An action you cannot take yet keeps its place but drops its light — the amber means "press me",
|
||||||
so a disabled button must not wear it. */
|
so a disabled button must not wear it. */
|
||||||
#actions button.blocked,#actions button:disabled{background:#232830;border:1px dashed #4a5361;
|
#actions button.blocked,#actions button:disabled{background:#232830;border:1px dashed #4a5361;
|
||||||
@@ -307,9 +340,10 @@ ul.blocked li{padding:2px 0}
|
|||||||
|
|
||||||
<!-- THE LOBBY (Phase 4) — shown instead of the game UI whenever there is no game yet to play: no
|
<!-- 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 +436,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 +456,7 @@ ul.blocked li{padding:2px 0}
|
|||||||
<span><b>Custom</b><br><span class="dim">Whatever you set below. Selected for you the moment you change a rule; it is scored as the type you started from.</span></span></label>
|
<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 +466,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 +540,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 +557,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 +590,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>
|
||||||
@@ -592,6 +627,208 @@ ul.blocked li{padding:2px 0}
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ===================================================================
|
||||||
|
SOLITAIRE SETUP — the same question multiplayer already asks first,
|
||||||
|
now asked here too (Jesse, 2026-08-29): a genuinely fresh visit deals
|
||||||
|
nothing until this screen's own Deal button is pressed. A saved game,
|
||||||
|
an explicit `?seed=`, or a URL already carrying a Deal's answers (any
|
||||||
|
of the shared block's fields — `hand` names the one always written)
|
||||||
|
all skip straight past this screen, exactly as `?lobby` already skips
|
||||||
|
past it into the lobby: those are not "no plan yet", they are a
|
||||||
|
choice already made, elsewhere.
|
||||||
|
|
||||||
|
THE SAME BLOCK THE DIALOG AND THE LOBBY USE, same shared module
|
||||||
|
(`settings-form.ts`), same order — three screens are one design now
|
||||||
|
instead of two. Only Solitaire can be dealt from here, so the other
|
||||||
|
four types are shown exactly as the in-game dialog shows them: present,
|
||||||
|
disabled, with a note pointing at the Multiplayer door instead.
|
||||||
|
==================================================================== -->
|
||||||
|
<div id="solitairesetup" hidden>
|
||||||
|
<header><b><a href="./index.html" class="home">Station Master</a></b> — <span class="dim">Solitaire</span></header>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>New solitaire game</h2>
|
||||||
|
<p class="ng-note">One railroad, one player, five full days by default — everything below is
|
||||||
|
yours to change before you deal. Clearing the Revenue floor wins; falling short loses.</p>
|
||||||
|
|
||||||
|
<!-- THE SAME THREE PARAMETERS THE LOBBY ASKS, in the same order, with the same note under them
|
||||||
|
(Jesse, 2026-08-30: "everything beneath that should be the same"). The table size is here
|
||||||
|
rather than hidden because it is one of the three things that describe a game, and leaving
|
||||||
|
it out made this screen a different form that happened to share a rules block. It is LOCKED
|
||||||
|
at one: a `LocalSession` runs the engine in this browser and a table needs a server, which
|
||||||
|
is the same reason the four multiplayer game types are shown disabled below. -->
|
||||||
|
<div class="lb-params">
|
||||||
|
<label class="ng-num"><span>Seed</span>
|
||||||
|
<input id="ss-seed" type="text" inputmode="numeric" autocomplete="off" placeholder="blank for a random seed"></label>
|
||||||
|
<label class="ng-num"><span>Players at the table</span>
|
||||||
|
<select id="ss-players" disabled>
|
||||||
|
<option value="1" selected>1</option>
|
||||||
|
</select></label>
|
||||||
|
<label class="ng-num"><span>Days</span>
|
||||||
|
<input id="ss-days" type="number" min="1" max="20" step="1" value="5"></label>
|
||||||
|
</div>
|
||||||
|
<p class="ng-note">The same seed and the same settings always deal the same railroad, so a game
|
||||||
|
can be shared, compared or replayed. Leave it blank for a random one.</p>
|
||||||
|
<p class="ng-note">Every chair must be filled before the game can start. For solitaire,
|
||||||
|
there’s only one player. For multiplayer, that must be filled by a person or a
|
||||||
|
bot. The number of players cannot be changed once the game is created.</p>
|
||||||
|
|
||||||
|
<h3>Game type (solitaire)</h3>
|
||||||
|
<div class="set-row" id="ss-type-row">
|
||||||
|
<label class="ng-radio"><input type="radio" name="ss-type" value="solitaire" checked>
|
||||||
|
<span><b>Solitaire</b><br><span class="dim">One railroad, one player. The whole Division is yours to run.</span></span></label>
|
||||||
|
<label class="ng-radio"><input type="radio" name="ss-type" value="coop">
|
||||||
|
<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="ss-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="ss-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="ss-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>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<details id="ss-settings" open>
|
||||||
|
<summary>Game settings</summary>
|
||||||
|
<p class="ng-note">Every rule the game type sets, and every one of them yours to change.
|
||||||
|
Changing any of them selects <b>Custom</b>, which keeps the scoring of the type you
|
||||||
|
started from; changing the game type resets all of them back to it. They are fixed when the
|
||||||
|
game is created and cannot be changed once it starts.</p>
|
||||||
|
<div class="set-groups">
|
||||||
|
|
||||||
|
<div class="set-group">
|
||||||
|
<h3>Starting hand</h3>
|
||||||
|
<p class="ng-note">What each player is dealt before the first turn. The hand limit is three
|
||||||
|
either way — deal six and the first turn is spent choosing which of them to keep.</p>
|
||||||
|
<div class="set-row" id="ss-hand-row">
|
||||||
|
<label class="ng-radio"><input type="radio" name="ss-hand" value="threeRandom">
|
||||||
|
<span><b>Three random cards</b><br><span class="dim">The original rule. At the hand limit already, and no guarantee of track.</span></span></label>
|
||||||
|
<label class="ng-radio"><input type="radio" name="ss-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="ss-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="ss-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="ss-extra-row">
|
||||||
|
<label class="ng-radio"><input type="radio" name="ss-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="ss-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="ss-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="ss-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="ss-passenger-row">
|
||||||
|
<label class="ng-num"><span>Passenger revenue per coach</span>
|
||||||
|
<input id="ss-passenger" type="number" min="0" max="5" step="1" value="1"></label>
|
||||||
|
<span class="set-hint" id="ss-passenger-hint"></span>
|
||||||
|
</div>
|
||||||
|
<div class="set-row" id="ss-freight-row">
|
||||||
|
<label class="ng-num"><span>Freight revenue per load</span>
|
||||||
|
<input id="ss-freight" type="number" min="0" max="5" step="1" value="1"></label>
|
||||||
|
<span class="set-hint" id="ss-freight-hint"></span>
|
||||||
|
</div>
|
||||||
|
<div class="set-row" id="ss-transit-row">
|
||||||
|
<label class="ng-num"><span>Train revenue per transit</span>
|
||||||
|
<input id="ss-transit" type="number" min="0" max="5" step="1" value="0"></label>
|
||||||
|
<span class="set-hint" id="ss-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="ss-minrev-row">
|
||||||
|
<label class="ng-gate"><input type="checkbox" id="ss-minrev-on" checked>
|
||||||
|
<span>You lose if Revenue at the end is under</span>
|
||||||
|
<input id="ss-minrev" type="number" min="0" step="1" class="gate-num"></label>
|
||||||
|
<span class="set-hint" id="ss-minrev-hint"></span>
|
||||||
|
</div>
|
||||||
|
<div class="set-row" id="ss-colday-row">
|
||||||
|
<label class="ng-gate"><input type="checkbox" id="ss-colday-on" checked>
|
||||||
|
<span>The game ends immediately and results in a loss if collisions in one Day reach</span>
|
||||||
|
<input id="ss-colday" type="number" min="0" step="1" class="gate-num"></label>
|
||||||
|
<span class="set-hint" id="ss-colday-hint"></span>
|
||||||
|
</div>
|
||||||
|
<div class="set-row" id="ss-coltotal-row">
|
||||||
|
<label class="ng-gate"><input type="checkbox" id="ss-coltotal-on" checked>
|
||||||
|
<span>The game ends immediately and results in a loss after this many collisions in the whole game</span>
|
||||||
|
<input id="ss-coltotal" type="number" min="0" step="1" class="gate-num"></label>
|
||||||
|
<span class="set-hint" id="ss-coltotal-hint"></span>
|
||||||
|
</div>
|
||||||
|
<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">Each one changes how the game plays.</p>
|
||||||
|
<div class="set-row" id="ss-visibility-row">
|
||||||
|
<label class="ng-num"><span>Reduced Visibility — five switching Moves instead of six in the
|
||||||
|
night Stages (1–3 and 11–12)</span>
|
||||||
|
<input id="ss-visibility" type="checkbox"></label>
|
||||||
|
<span class="set-hint" id="ss-visibility-hint"></span>
|
||||||
|
</div>
|
||||||
|
<div class="set-row" id="ss-rotation-row">
|
||||||
|
<label class="ng-num"><span>Employee Rotation — not applicable for solitaire</span>
|
||||||
|
<input id="ss-rotation" type="checkbox" disabled></label>
|
||||||
|
<span class="set-hint" id="ss-rotation-hint"></span>
|
||||||
|
</div>
|
||||||
|
<div class="set-row" id="ss-toolbox-row">
|
||||||
|
<label class="ng-num"><span>Emergency Toolbox — start holding a Red Flag, so a hand of four;
|
||||||
|
play or discard down to three on the first turn</span>
|
||||||
|
<input id="ss-toolbox" type="checkbox"></label>
|
||||||
|
<span class="set-hint" id="ss-toolbox-hint"></span>
|
||||||
|
</div>
|
||||||
|
<div class="set-row" id="ss-tossloco-row">
|
||||||
|
<label class="ng-num"><span>A Timetabled train may be discarded — toss it face-up to a
|
||||||
|
Department slot. 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="ss-tossloco" type="checkbox"></label>
|
||||||
|
<span class="set-hint" id="ss-tossloco-hint"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<!-- Shown only when `station-master.save.v1` holds a game. Dealing from this screen CLEARS that
|
||||||
|
save (`commitNewGame` calls `clearSave`), so without a way back the door would be a way to
|
||||||
|
lose a game in progress — and the door is reached by clicking "Play solitaire", which nobody
|
||||||
|
reads as "discard what I was playing". -->
|
||||||
|
<p id="ss-saved-note" hidden><b>You have a solitaire game in progress.</b> Creating a new game
|
||||||
|
replaces it permanently — there is no undo. Choose <b>Continue saved game</b> to pick it
|
||||||
|
up where you left off.</p>
|
||||||
|
|
||||||
|
<menu class="ng-buttons">
|
||||||
|
<button id="ss-resume" type="button" hidden>Continue saved game</button>
|
||||||
|
<button id="ss-deal" type="button">Create new game</button>
|
||||||
|
</menu>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="gameui" hidden>
|
<div id="gameui" hidden>
|
||||||
<div class="topbar">
|
<div class="topbar">
|
||||||
<header>
|
<header>
|
||||||
@@ -600,20 +837,23 @@ ul.blocked li{padding:2px 0}
|
|||||||
<!-- The objective, and nothing else: score, target, Days left. The "behind the pace" chip and the
|
<!-- The objective, and nothing else: score, target, Days left. The "behind the pace" chip and the
|
||||||
engine's guess at what your score ought to be were noise on the one line that must not wrap. -->
|
engine's guess at what your score ought to be were noise on the one line that must not wrap. -->
|
||||||
<span id="objective" class="pace">—</span>
|
<span id="objective" class="pace">—</span>
|
||||||
<span class="dim">seed <span id="seed">—</span></span>
|
<!-- THE COLLISION COUNTS, WHICH ARE A LIVE SCORE AND NOT A SETTING (TODO #28). The Frame has
|
||||||
|
carried `collisionsToday` and `collisionsTotal` since v0.7.0 and nothing on the board drew
|
||||||
|
them, so the one victory condition that can end a game early was invisible while it ran.
|
||||||
|
Empty and collapsed when both limits are 0 — a game that cannot end this way should not be
|
||||||
|
counting towards it. The limits themselves live in the This Game card; this is progress. -->
|
||||||
|
<span class="dim" id="collisions" title=""></span>
|
||||||
<!-- THE GAME CODE SURVIVES THE LOBBY. It used to end at `Lobby.Start` — the code was never carried
|
<!-- THE GAME CODE SURVIVES THE LOBBY. It used to end at `Lobby.Start` — the code was never carried
|
||||||
into `LobbyReady` — so a seated player could not say which game they were in, could not match
|
into `LobbyReady` — so a seated player could not say which game they were in, could not match
|
||||||
it against the administrator's Games in Progress list, and could not pass it to a latecomer.
|
it against the administrator's Games in Progress list, and could not pass it to a latecomer.
|
||||||
Empty (and collapsed) in solitaire, where there is no code. -->
|
Empty (and collapsed) in solitaire, where there is no code. -->
|
||||||
<span class="dim" id="gamecode" title="The code this game was created under. The administrator's Games in Progress list uses it, and it is how you say which game you mean."></span>
|
<span class="dim" id="gamecode" title="The code this game was created under. The administrator's Games in Progress list uses it, and it is how you say which game you mean."></span>
|
||||||
<!-- Co-op, Competitive, Cutthroat or Custom, derived from the config the Frame carries
|
<!-- The seed, the seat, the game type and every house rule MOVED TO THE THIS GAME CARD, 2026-08-30
|
||||||
(`presets.ts`). A Cutthroat game used to look exactly like a Co-op one from the board. -->
|
(TODO #28). Jesse: "we can give complete information about all the game options and not take
|
||||||
<span class="dim" id="gametype" title=""></span>
|
up valuable real estate at the top of the screen… it is not something that they're likely to
|
||||||
<!-- WHICH RULES THIS GAME IS BEING PLAYED UNDER. The settings are chosen when the game is dealt
|
need all the time." What stays here is what is glanced at every turn — Revenue, the objective,
|
||||||
and then never mentioned again, which makes a playtest note ("scored 4") unreadable a week
|
the collision counts — plus the game code, which is identity rather than settings: it is how
|
||||||
later: at 0 revenue per transit that is a different game from the same seed at 5. Short enough
|
you say WHICH game you are in, out loud, without opening anything. -->
|
||||||
to keep the header on one line; the tooltip spells it out. -->
|
|
||||||
<span class="dim" id="houserules" title="">—</span>
|
|
||||||
<button id="sound" title="Whistle at the end of each Stage, the crossing bell at the end of each Day, and the conductor when a train is built. Currently synthesised, not recorded.">🔇 muted</button>
|
<button id="sound" title="Whistle at the end of each Stage, the crossing bell at the end of each Day, and the conductor when a train is built. Currently synthesised, not recorded.">🔇 muted</button>
|
||||||
<!-- BOARD ZOOM. Applies to the Division map and the Office Area grid alike — both already scroll
|
<!-- BOARD ZOOM. Applies to the Division map and the Office Area grid alike — both already scroll
|
||||||
horizontally (`#division`, `#grid`) when they run wide, so this only ever needs to resize the
|
horizontally (`#division`, `#grid`) when they run wide, so this only ever needs to resize the
|
||||||
@@ -623,7 +863,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
|
||||||
@@ -657,7 +897,7 @@ ul.blocked li{padding:2px 0}
|
|||||||
<section id="district">
|
<section id="district">
|
||||||
<h2>Your Office Area
|
<h2>Your Office Area
|
||||||
<span class="dim" style="text-transform:none;letter-spacing:0">— hover any card for the full explanation</span>
|
<span class="dim" style="text-transform:none;letter-spacing:0">— hover any card for the full explanation</span>
|
||||||
<button id="districttoggle" class="ghost" title="Auto-hide keeps the district open during Local Operations and Cargo — the phases that change it — and folds it otherwise. Click to pin it open or hidden instead.">auto-hide: on</button>
|
<span id="districttoggle" class="seg" role="group" aria-label="When to show your Office Area"><button id="dm-auto" class="ghost" type="button" title="Open during Local Operations and Cargo — the phases that change the district — and folded otherwise.">Auto-hide</button><button id="dm-open" class="ghost" type="button" title="Keep the Office Area open in every phase.">Always show</button><button id="dm-closed" class="ghost" type="button" title="Keep the Office Area folded in every phase. The summary line stays, so it reads as folded rather than missing.">Always hide</button></span>
|
||||||
</h2>
|
</h2>
|
||||||
<div id="districtsummary" class="dim"></div>
|
<div id="districtsummary" class="dim"></div>
|
||||||
<!-- THE RULE THAT SHAPES EVERY DISTRICT, said once where the district is.
|
<!-- THE RULE THAT SHAPES EVERY DISTRICT, said once where the district is.
|
||||||
@@ -701,190 +941,34 @@ ul.blocked li{padding:2px 0}
|
|||||||
</section>
|
</section>
|
||||||
<section><h2>Blocked — why nothing is moving</h2><ul class="blocked" id="blocked"></ul></section>
|
<section><h2>Blocked — why nothing is moving</h2><ul class="blocked" id="blocked"></ul></section>
|
||||||
<section><h2>Facilities</h2><div id="facs"></div></section>
|
<section><h2>Facilities</h2><div id="facs"></div></section>
|
||||||
|
<!-- THIS GAME — the settings it was dealt under, off the top line and out of the way (TODO #28).
|
||||||
|
Last in the column and folded by default because it is looked up, not watched: "Oh wait,
|
||||||
|
what did we set that to?" The body is `rulesListHtml`, the same renderer the join preview
|
||||||
|
and the seating screen draw, so what you agreed to in the lobby and what you can read
|
||||||
|
mid-game cannot drift apart. -->
|
||||||
|
<section id="gamecard" class="folded"><h2>This Game
|
||||||
|
<button id="gamecardtoggle" class="ghost" type="button" title="The seed, the seat, the game type and every rule this game was dealt under.">show</button>
|
||||||
|
</h2>
|
||||||
|
<div id="gamecardsummary" class="dim"></div>
|
||||||
|
<div id="gamecardbody"></div>
|
||||||
|
</section>
|
||||||
</div>
|
</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:
|
||||||
@@ -901,10 +985,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,
|
||||||
|
|||||||
+2
-1
@@ -26,6 +26,7 @@ import { createGame } from '../engine/setup.ts';
|
|||||||
import { snapshot } from '../sim/view.ts';
|
import { snapshot } from '../sim/view.ts';
|
||||||
import type { Intent } from '../engine/intents.ts';
|
import type { Intent } from '../engine/intents.ts';
|
||||||
import { SOLO_CONFIG } from './game.ts';
|
import { SOLO_CONFIG } from './game.ts';
|
||||||
|
import { actingPlayer } from '../engine/state.ts';
|
||||||
|
|
||||||
type Save = { seed: number; history: Intent[] };
|
type Save = { seed: number; history: Intent[] };
|
||||||
type Entry = { file: string; title: string; note?: string; seed?: number };
|
type Entry = { file: string; title: string; note?: string; seed?: number };
|
||||||
@@ -69,7 +70,7 @@ function rebuild(save: Save): { steps: Step[]; stoppedEarly: boolean } {
|
|||||||
push(pump(s));
|
push(pump(s));
|
||||||
let stoppedEarly = false;
|
let stoppedEarly = false;
|
||||||
for (const intent of save.history) {
|
for (const intent of save.history) {
|
||||||
const actor = s.clock.pendingDecision !== null ? s.clock.superintendent : s.clock.currentActor;
|
const actor = actingPlayer(s);
|
||||||
if (actor === null || s.status !== 'active') break;
|
if (actor === null || s.status !== 'active') break;
|
||||||
const r = applyIntent(s, actor, intent);
|
const r = applyIntent(s, actor, intent);
|
||||||
if (!r.ok) {
|
if (!r.ok) {
|
||||||
|
|||||||
+158
-22
@@ -495,7 +495,7 @@ describe('the Superintendent clearance interrupt (§8.1)', () => {
|
|||||||
assert.equal(r.needsInput, true, 'the phase must stop and ask');
|
assert.equal(r.needsInput, true, 'the phase must stop and ask');
|
||||||
assert.notEqual(s.clock.pendingDecision, null);
|
assert.notEqual(s.clock.pendingDecision, null);
|
||||||
assert.equal(s.clock.pendingDecision!.train, 'behind');
|
assert.equal(s.clock.pendingDecision!.train, 'behind');
|
||||||
assert.equal(s.clock.pendingDecision!.occupiedBy, 'ahead');
|
assert.equal((s.clock.pendingDecision as { occupiedBy: string }).occupiedBy, 'ahead');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not ask when the train ahead is coming the other way — that is an absolute bar', () => {
|
it('does not ask when the train ahead is coming the other way — that is an absolute bar', () => {
|
||||||
@@ -541,7 +541,7 @@ describe('the Superintendent clearance interrupt (§8.1)', () => {
|
|||||||
it('clears the decision once the Superintendent rules', () => {
|
it('clears the decision once the Superintendent rules', () => {
|
||||||
const s = game();
|
const s = game();
|
||||||
s.clock.phase = 'mainline';
|
s.clock.phase = 'mainline';
|
||||||
s.clock.pendingDecision = { train: 'a', occupiedBy: 'b' };
|
s.clock.pendingDecision = { kind: 'clearance', train: 'a', occupiedBy: 'b' };
|
||||||
const r = applyIntent(s, 0, { type: 'mainline.clearance', allow: false });
|
const r = applyIntent(s, 0, { type: 'mainline.clearance', allow: false });
|
||||||
assert.ok(r.ok);
|
assert.ok(r.ok);
|
||||||
assert.equal(s.clock.pendingDecision, null);
|
assert.equal(s.clock.pendingDecision, null);
|
||||||
@@ -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');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -749,43 +772,156 @@ describe('MILESTONE: a full solitaire game runs headless', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('X18 Circus Train — a point for standing still', () => {
|
describe('X18 Circus / X17 Campaign — a point for setting up (Gitea#13)', () => {
|
||||||
it('pays once for a Stage spent stopped, and never again', () => {
|
|
||||||
/**
|
/**
|
||||||
* REPORTED: "Circus train TX18 was stopped on a siding for a full Stage and I did not get my
|
* REPORTED originally: "Circus train TX18 was stopped on a siding for a full Stage and I did not
|
||||||
* Revenue point." It never could: `stopEarnsPoint` was declared on the profile and read
|
* get my Revenue point." It never could: `stopEarnsPoint` was declared on the profile and read
|
||||||
* NOWHERE, along with eight other special-train rules. The one card in the deck that pays for
|
* NOWHERE, along with eight other special-train rules.
|
||||||
* standing still paid nothing.
|
*
|
||||||
|
* REDEFINED by Gitea#13 (Jesse, 2026-08-29), and these tests carry the three parts of that
|
||||||
|
* ruling: the point is paid ONCE PER OFFICE AREA rather than once per game, only when the train
|
||||||
|
* is FULLY LOADED, and only in an Office Area at all.
|
||||||
*/
|
*/
|
||||||
const s = game();
|
const circusAt = (s: GameState, seat: number, coord: { row: number; col: number }, consist: unknown[]) => {
|
||||||
s.clock.phase = 'mainline';
|
s.clock.phase = 'mainline';
|
||||||
s.trays.set('circus', {
|
s.trays.set('circus', {
|
||||||
id: 'circus', trainNumber: 18, trainIsExtra: true, engineAt: 0,
|
id: 'circus', trainNumber: 18, trainIsExtra: true, engineAt: 0,
|
||||||
consist: [], direction: 'east',
|
consist, direction: 'east',
|
||||||
position: { at: 'grid', seat: 0, coord: { row: -1, col: 0 } },
|
position: { at: 'grid', seat, coord },
|
||||||
movesUsed: 0,
|
movesUsed: 0,
|
||||||
} as never);
|
} as never);
|
||||||
// A card under it, so the crew is somewhere real rather than off the grid.
|
// A card under it, so the crew is somewhere real rather than off the grid.
|
||||||
areaOf(s, 0).grid.set('-1,0', {
|
areaOf(s, seat as never).grid.set(`${coord.row},${coord.col}`, {
|
||||||
geometry: { kind: 'track', geometry: 'straight' },
|
geometry: { kind: 'track', geometry: 'straight' },
|
||||||
baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [],
|
baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [],
|
||||||
} as never);
|
} as never);
|
||||||
|
};
|
||||||
|
|
||||||
|
const loaded = [
|
||||||
|
{ type: 'boxcar', loaded: true },
|
||||||
|
{ type: 'boxcar', loaded: true },
|
||||||
|
{ type: 'coach', loaded: true },
|
||||||
|
{ type: 'caboose', loaded: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
const runPhase = (s: GameState) => {
|
||||||
|
s.clock.phase = 'mainline';
|
||||||
|
s.movedThisPhase = new Set();
|
||||||
|
return pump(s);
|
||||||
|
};
|
||||||
|
|
||||||
|
it('pays a fully loaded Circus for a Stage spent set up', () => {
|
||||||
|
const s = game();
|
||||||
|
circusAt(s, 0, { row: -1, col: 0 }, loaded);
|
||||||
const before = s.players[0]!.revenue;
|
const before = s.players[0]!.revenue;
|
||||||
const first = pump(s);
|
|
||||||
assert.ok(
|
assert.ok(
|
||||||
first.some((e) => e.type === 'trainStoodStill' && e.trainNumber === 18),
|
pump(s).some((e) => e.type === 'trainStoodStill' && e.trainNumber === 18),
|
||||||
'the Circus Train stood still for a Stage and earned nothing',
|
'the Circus Train stood still for a Stage and earned nothing',
|
||||||
);
|
);
|
||||||
assert.equal(s.players[0]!.revenue, before + 1, 'the point was not paid');
|
assert.equal(s.players[0]!.revenue, before + 1, 'the point was not paid');
|
||||||
|
});
|
||||||
|
|
||||||
// "One turn stopped" — once. A train that goes on standing there does not keep earning.
|
it('pays once per Office Area, however long it parks there', () => {
|
||||||
const paidAgain = () => {
|
// "Once per stop in an office area" — a train that goes on standing in the same district does
|
||||||
|
// not keep earning. This is the half that was already true, for a different reason.
|
||||||
|
const s = game();
|
||||||
|
circusAt(s, 0, { row: -1, col: 0 }, loaded);
|
||||||
|
pump(s);
|
||||||
|
assert.ok(!runPhase(s).some((e) => e.type === 'trainStoodStill'),
|
||||||
|
'the Circus collected twice for the same set-up');
|
||||||
|
assert.ok(!runPhase(s).some((e) => e.type === 'trainStoodStill'),
|
||||||
|
'the Circus collected a third time for the same set-up');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pays AGAIN in a different district — each player can be visited', () => {
|
||||||
|
/**
|
||||||
|
* The half that is new. "In a multiplayer game, each player could score if the circus stops in
|
||||||
|
* their area" — so the claim is per seat, and a touring Circus is paid by each district it sets
|
||||||
|
* up in. Before Gitea#13 this paid once per GAME and the second district got nothing.
|
||||||
|
*/
|
||||||
|
const s = createGame({
|
||||||
|
id: 'g', seed: 5, config: baseConfig({ mode: 'competitive' }), playerNames: ['A', 'B'],
|
||||||
|
});
|
||||||
|
circusAt(s, 0, { row: -1, col: 0 }, loaded);
|
||||||
|
pump(s);
|
||||||
|
const paidFirst = s.players.map((p) => p.revenue);
|
||||||
|
|
||||||
|
// The same train, moved into the other player's district.
|
||||||
|
const tray = s.trays.get('circus')!;
|
||||||
|
areaOf(s, 1 as never).grid.set('-1,0', {
|
||||||
|
geometry: { kind: 'track', geometry: 'straight' },
|
||||||
|
baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [],
|
||||||
|
} as never);
|
||||||
|
tray.position = { at: 'grid', seat: 1, coord: { row: -1, col: 0 } } as never;
|
||||||
|
|
||||||
|
assert.ok(runPhase(s).some((e) => e.type === 'trainStoodStill'),
|
||||||
|
'the Circus set up in a second district and earned nothing');
|
||||||
|
const owner = s.seating[1]!;
|
||||||
|
assert.equal(
|
||||||
|
s.players[owner]!.revenue,
|
||||||
|
paidFirst[owner]! + 1,
|
||||||
|
'the point did not go to whoever sits in the district it stopped in',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pays nothing when the cars are empty — "not much of a circus"', () => {
|
||||||
|
const s = game();
|
||||||
|
circusAt(s, 0, { row: -1, col: 0 }, [
|
||||||
|
{ type: 'boxcar', loaded: false },
|
||||||
|
{ type: 'coach', loaded: true },
|
||||||
|
{ type: 'caboose', loaded: true },
|
||||||
|
]);
|
||||||
|
const before = s.players[0]!.revenue;
|
||||||
|
assert.ok(!pump(s).some((e) => e.type === 'trainStoodStill'),
|
||||||
|
'an empty car aboard still collected the set-up point');
|
||||||
|
assert.equal(s.players[0]!.revenue, before, 'Revenue moved for a train that was not full');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pays nothing to a train carrying nothing at all', () => {
|
||||||
|
// `every` on an empty list is vacuously true, so the emptiest train of the lot is exactly the
|
||||||
|
// one a careless test would pay.
|
||||||
|
const s = game();
|
||||||
|
circusAt(s, 0, { row: -1, col: 0 }, []);
|
||||||
|
assert.ok(!pump(s).some((e) => e.type === 'trainStoodStill'),
|
||||||
|
'a Circus carrying nothing was paid for setting up');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pays nothing for standing out on the Mainline', () => {
|
||||||
|
/**
|
||||||
|
* It used to, and it misattributed the point: `playerAtSeat` needs a seat, there is none off
|
||||||
|
* the grid, and the fallback handed it to PLAYER 0 wherever the train was standing. Jesse's
|
||||||
|
* ruling scopes the rule to Office Areas, which removes the bug rather than patching it.
|
||||||
|
*/
|
||||||
|
const s = game();
|
||||||
s.clock.phase = 'mainline';
|
s.clock.phase = 'mainline';
|
||||||
s.movedThisPhase = new Set();
|
const index = s.division.nodes.findIndex((n) => n.kind === 'mainline');
|
||||||
return pump(s).some((e) => e.type === 'trainStoodStill');
|
s.trays.set('circus', {
|
||||||
};
|
id: 'circus', trainNumber: 18, trainIsExtra: true, engineAt: 0,
|
||||||
assert.ok(!paidAgain(), 'the Circus Train collected a second time for the same set-up');
|
consist: loaded, direction: 'east',
|
||||||
|
position: { at: 'mainline', index },
|
||||||
|
movesUsed: 0,
|
||||||
|
} as never);
|
||||||
|
const before = s.players[0]!.revenue;
|
||||||
|
pump(s);
|
||||||
|
assert.equal(s.players[0]!.revenue, before, 'a Mainline set-up paid a point');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pays the Campaign Train only when its candidate is aboard', () => {
|
||||||
|
// X17 carries one coach and no freight, so "fully loaded" is exactly "the coach is occupied".
|
||||||
|
// It earned nothing at all before Gitea#13 — it had `stopThenExpedite` and no scoring rule.
|
||||||
|
const occupied = game();
|
||||||
|
circusAt(occupied, 0, { row: -1, col: 0 }, [{ type: 'coach', loaded: true }]);
|
||||||
|
occupied.trays.get('circus')!.trainNumber = 17;
|
||||||
|
const beforeOccupied = occupied.players[0]!.revenue;
|
||||||
|
pump(occupied);
|
||||||
|
assert.equal(occupied.players[0]!.revenue, beforeOccupied + 1, 'a full Campaign Train earned nothing');
|
||||||
|
|
||||||
|
const empty = game();
|
||||||
|
circusAt(empty, 0, { row: -1, col: 0 }, [{ type: 'coach', loaded: false }]);
|
||||||
|
empty.trays.get('circus')!.trainNumber = 17;
|
||||||
|
const beforeEmpty = empty.players[0]!.revenue;
|
||||||
|
pump(empty);
|
||||||
|
assert.equal(empty.players[0]!.revenue, beforeEmpty, 'an empty Campaign Train was paid for its speech');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1291,7 +1427,7 @@ describe('an Extra starts where the player puts it (Gitea#4)', () => {
|
|||||||
const r = advance(s);
|
const r = advance(s);
|
||||||
assert.equal(r.needsInput, true, 'the phase must stop and ask');
|
assert.equal(r.needsInput, true, 'the phase must stop and ask');
|
||||||
assert.equal(s.clock.pendingDecision?.train, tray.id);
|
assert.equal(s.clock.pendingDecision?.train, tray.id);
|
||||||
assert.equal(s.clock.pendingDecision?.occupiedBy, 'ahead');
|
assert.equal((s.clock.pendingDecision as { occupiedBy: string } | null)?.occupiedBy, 'ahead');
|
||||||
|
|
||||||
// HOLD keeps it in the yard.
|
// HOLD keeps it in the yard.
|
||||||
assert.ok(applyIntent(s, s.clock.superintendent, { type: 'mainline.clearance', allow: false }).ok);
|
assert.ok(applyIntent(s, s.clock.superintendent, { type: 'mainline.clearance', allow: false }).ok);
|
||||||
|
|||||||
+2
-2
@@ -956,7 +956,7 @@ describe('the Superintendent clearance ruling (§8.1)', () => {
|
|||||||
const s = game();
|
const s = game();
|
||||||
s.clock.phase = 'mainline';
|
s.clock.phase = 'mainline';
|
||||||
s.clock.currentActor = null; // nobody's turn — yet the Superintendent must still rule
|
s.clock.currentActor = null; // nobody's turn — yet the Superintendent must still rule
|
||||||
s.clock.pendingDecision = { train: 'tray0', occupiedBy: 'tray1' };
|
s.clock.pendingDecision = { kind: 'clearance', train: 'tray0', occupiedBy: 'tray1' };
|
||||||
const r = applyIntent(s, 0, { type: 'mainline.clearance', allow: false });
|
const r = applyIntent(s, 0, { type: 'mainline.clearance', allow: false });
|
||||||
assert.ok(r.ok);
|
assert.ok(r.ok);
|
||||||
assert.equal(s.clock.pendingDecision, null);
|
assert.equal(s.clock.pendingDecision, null);
|
||||||
@@ -964,7 +964,7 @@ describe('the Superintendent clearance ruling (§8.1)', () => {
|
|||||||
|
|
||||||
it('is refused to a player who is not the Superintendent', () => {
|
it('is refused to a player who is not the Superintendent', () => {
|
||||||
const s = game();
|
const s = game();
|
||||||
s.clock.pendingDecision = { train: 'tray0', occupiedBy: 'tray1' };
|
s.clock.pendingDecision = { kind: 'clearance', train: 'tray0', occupiedBy: 'tray1' };
|
||||||
s.clock.superintendent = 1;
|
s.clock.superintendent = 1;
|
||||||
assert.equal(check(s, 0, { type: 'mainline.clearance', allow: true }), 'NOT_SUPERINTENDENT');
|
assert.equal(check(s, 0, { type: 'mainline.clearance', allow: true }), 'NOT_SUPERINTENDENT');
|
||||||
});
|
});
|
||||||
|
|||||||
+112
-5
@@ -14,7 +14,7 @@ import { applyIntent, areaOf, check, hasDistrictEnhancement, isProtectedFromDera
|
|||||||
import { ENHANCEMENT_RULES, enhancementRule, trainProfile } from '../src/engine/content.ts';
|
import { ENHANCEMENT_RULES, enhancementRule, trainProfile } from '../src/engine/content.ts';
|
||||||
import { createGame } from '../src/engine/setup.ts';
|
import { createGame } from '../src/engine/setup.ts';
|
||||||
import type { GameConfig, GameState, GridCoord, TrackCard } from '../src/engine/state.ts';
|
import type { GameConfig, GameState, GridCoord, TrackCard } from '../src/engine/state.ts';
|
||||||
import { coordKey, subdivisions, turnOf } from '../src/engine/state.ts';
|
import { coordKey, decisionActor, subdivisions, turnOf } from '../src/engine/state.ts';
|
||||||
|
|
||||||
const config: GameConfig = {
|
const config: GameConfig = {
|
||||||
mode: 'solitaire',
|
mode: 'solitaire',
|
||||||
@@ -287,17 +287,124 @@ describe('Interlocking and Yard Office relieve the Office', () => {
|
|||||||
assert.equal(s.players[0]!.revenue, -5);
|
assert.equal(s.players[0]!.revenue, -5);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('diverts a coachless train to the Yard Office', () => {
|
/**
|
||||||
|
* §11, THE YARD OFFICE (Gitea#5) — offered, not imposed, and only down a route that exists.
|
||||||
|
*
|
||||||
|
* Jesse: "you have to ask if non-coach trains wish to go in there, rather than to the office",
|
||||||
|
* "if the Yard Office is not accessible in one move, you should not get the option", and cars on
|
||||||
|
* the way in "result in a crash". All three were missing: the train was teleported onto the card.
|
||||||
|
*/
|
||||||
|
const answer = (s: GameState, take: boolean) => {
|
||||||
|
const who = decisionActor(s);
|
||||||
|
assert.notEqual(who, null, 'nothing was pending, so there was nothing to answer');
|
||||||
|
const r = applyIntent(s, who!, { type: 'mainline.yardOffice', take });
|
||||||
|
assert.ok(r.ok, 'the district owner could not answer the Yard Office offer');
|
||||||
|
advance(s);
|
||||||
|
};
|
||||||
|
|
||||||
|
it('OFFERS the Yard Office to the district owner rather than diverting automatically', () => {
|
||||||
const s = game();
|
const s = game();
|
||||||
const card = straight();
|
const card = straight();
|
||||||
card.enhancements.push('yardOffice');
|
card.enhancements.push('yardOffice');
|
||||||
addCard(s, at(-1, 0), card);
|
addCard(s, at(0, 2), card);
|
||||||
const id = inbound(s, [{ type: 'hopper', loaded: true }]);
|
const id = inbound(s, [{ type: 'hopper', loaded: true }]);
|
||||||
|
|
||||||
advance(s);
|
advance(s);
|
||||||
|
assert.equal(s.clock.pendingDecision?.kind, 'yardOffice', 'the phase did not stop to ask');
|
||||||
|
assert.equal(decisionActor(s), 0, 'the question went to the wrong player');
|
||||||
const pos = s.trays.get(id)!.position;
|
const pos = s.trays.get(id)!.position;
|
||||||
assert.ok(pos.at === 'grid' && pos.coord.row === -1, 'arrived at the Yard Office');
|
assert.ok(pos.at !== 'grid' || pos.coord.col !== 2, 'the train moved before anyone answered');
|
||||||
assert.ok(!areaOf(s, 0).adOccupancy.includes(id), 'did not take an A/D track');
|
});
|
||||||
|
|
||||||
|
it('takes the Yard Office when the owner says yes', () => {
|
||||||
|
const s = game();
|
||||||
|
const card = straight();
|
||||||
|
card.enhancements.push('yardOffice');
|
||||||
|
addCard(s, at(0, 2), card);
|
||||||
|
const id = inbound(s, [{ type: 'hopper', loaded: true }]);
|
||||||
|
|
||||||
|
advance(s);
|
||||||
|
answer(s, true);
|
||||||
|
const pos = s.trays.get(id)!.position;
|
||||||
|
assert.ok(pos.at === 'grid' && pos.coord.col === 2, 'did not arrive at the Yard Office');
|
||||||
|
assert.ok(!areaOf(s, 0).adOccupancy.includes(id), 'took an A/D track anyway');
|
||||||
|
assert.equal(s.players[0]!.revenue, 0, 'a clear lead should not have collided');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('goes to the Train Order Office when the owner says no', () => {
|
||||||
|
// "They can of course still choose to have the train go to the standard office."
|
||||||
|
const s = game();
|
||||||
|
const card = straight();
|
||||||
|
card.enhancements.push('yardOffice');
|
||||||
|
addCard(s, at(0, 2), card);
|
||||||
|
const id = inbound(s, [{ type: 'hopper', loaded: true }]);
|
||||||
|
|
||||||
|
advance(s);
|
||||||
|
answer(s, false);
|
||||||
|
assert.ok(areaOf(s, 0).adOccupancy.includes(id), 'declining did not put it on an A/D track');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not offer what cannot be reached, and says why in the history', () => {
|
||||||
|
/**
|
||||||
|
* Jesse, 2026-08-29: "make sure this is logged in history — why can't move so user knows why
|
||||||
|
* they can't get to yard." A silent absence is indistinguishable from a broken feature, which
|
||||||
|
* is how the missing reachability check survived this long.
|
||||||
|
*/
|
||||||
|
const s = game();
|
||||||
|
const card = straight();
|
||||||
|
card.enhancements.push('yardOffice');
|
||||||
|
// Far off the Running Track, with nothing laid between: no route in one move.
|
||||||
|
addCard(s, at(3, 4), card);
|
||||||
|
inbound(s, [{ type: 'hopper', loaded: true }]);
|
||||||
|
|
||||||
|
const events = advance(s).events;
|
||||||
|
assert.equal(s.clock.pendingDecision, null, 'offered a Yard Office it cannot reach');
|
||||||
|
const said = events.find(
|
||||||
|
(e) => e.type === 'trainDiverted' && e.reason.includes('could not be offered'),
|
||||||
|
);
|
||||||
|
assert.ok(said, `nothing in the history explains why:\n${JSON.stringify(events, null, 1)}`);
|
||||||
|
assert.match(
|
||||||
|
(said as { reason: string }).reason,
|
||||||
|
/one move/,
|
||||||
|
'the reason does not say it is out of reach in one move',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('offers a fouled lead, and taking it collides', () => {
|
||||||
|
/**
|
||||||
|
* The third missing condition. "Just like other trains finding cars on the tracks you use to
|
||||||
|
* get into either result in a crash" — and Jesse's ruling keeps the OFFER: a route that exists
|
||||||
|
* is offered, and the consequence of taking it is the player's. §8.3 already reads cars in the
|
||||||
|
* path of an arriving train as a collision rather than a coupling.
|
||||||
|
*/
|
||||||
|
const s = game();
|
||||||
|
const card = straight();
|
||||||
|
card.enhancements.push('yardOffice');
|
||||||
|
addCard(s, at(0, 2), card);
|
||||||
|
// A car standing on the lead between the Office and the yard.
|
||||||
|
areaOf(s, 0).grid.get(coordKey(at(0, 1)))!.standing = [{ type: 'boxcar', loaded: false }];
|
||||||
|
const id = inbound(s, [{ type: 'hopper', loaded: true }]);
|
||||||
|
|
||||||
|
advance(s);
|
||||||
|
assert.equal(s.clock.pendingDecision?.kind, 'yardOffice', 'a fouled lead was not offered at all');
|
||||||
|
|
||||||
|
answer(s, true);
|
||||||
|
assert.equal(s.players[0]!.revenue, -5, 'running through standing cars did not collide');
|
||||||
|
assert.ok(!s.trays.has(id), 'the train survived the collision');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('declining a fouled lead is safe — the standard Office is unaffected', () => {
|
||||||
|
const s = game();
|
||||||
|
const card = straight();
|
||||||
|
card.enhancements.push('yardOffice');
|
||||||
|
addCard(s, at(0, 2), card);
|
||||||
|
areaOf(s, 0).grid.get(coordKey(at(0, 1)))!.standing = [{ type: 'boxcar', loaded: false }];
|
||||||
|
const id = inbound(s, [{ type: 'hopper', loaded: true }]);
|
||||||
|
|
||||||
|
advance(s);
|
||||||
|
answer(s, false);
|
||||||
|
assert.equal(s.players[0]!.revenue, 0, 'declining the Yard Office still cost a collision');
|
||||||
|
assert.ok(areaOf(s, 0).adOccupancy.includes(id), 'the train did not reach the Office');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not divert a train carrying coaches', () => {
|
it('does not divert a train carrying coaches', () => {
|
||||||
|
|||||||
@@ -48,6 +48,15 @@ const KNOWN_UNREDUCED = [
|
|||||||
'dispatchBonusUsed',
|
'dispatchBonusUsed',
|
||||||
'expediteFault',
|
'expediteFault',
|
||||||
'phaseBegan',
|
'phaseBegan',
|
||||||
|
/**
|
||||||
|
* §Q, Red Flags (Gitea#19). The flag comes down inside the phase driver as it stops a train, so
|
||||||
|
* this is described rather than reduced like everything else here.
|
||||||
|
*
|
||||||
|
* ADDED DELIBERATELY, and it cost a bug first: the flag was originally taken down in a `reduce`
|
||||||
|
* case, which never fires for an event `advance.ts` emits — so it stayed up and held every train
|
||||||
|
* that came. That is precisely the failure this list exists to make visible.
|
||||||
|
*/
|
||||||
|
'redFlagSpent',
|
||||||
// Employee Rotation moves `seating` in the phase driver and then describes what it did, which is
|
// Employee Rotation moves `seating` in the phase driver and then describes what it did, which is
|
||||||
// the pattern every entry on this list follows.
|
// the pattern every entry on this list follows.
|
||||||
'seatsRotated',
|
'seatsRotated',
|
||||||
|
|||||||
+240
-46
@@ -28,7 +28,7 @@ import {
|
|||||||
import { createGame } from '../src/engine/setup.ts';
|
import { createGame } from '../src/engine/setup.ts';
|
||||||
import type { GameEvent } from '../src/engine/events.ts';
|
import type { GameEvent } from '../src/engine/events.ts';
|
||||||
import type { GameConfig, GameState, GridCoord, TrackCard } from '../src/engine/state.ts';
|
import type { GameConfig, GameState, GridCoord, TrackCard } from '../src/engine/state.ts';
|
||||||
import { coordKey, turnOf } from '../src/engine/state.ts';
|
import { coordKey, decisionActor, turnOf } from '../src/engine/state.ts';
|
||||||
import { snapshot } from '../src/sim/view.ts';
|
import { snapshot } from '../src/sim/view.ts';
|
||||||
|
|
||||||
const config: GameConfig = {
|
const config: GameConfig = {
|
||||||
@@ -317,77 +317,177 @@ describe('Realignment converts one Mainline type to another', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Red Flags protect a stopped train', () => {
|
describe('Red Flags hold a train out of your Limits (Gitea#19)', () => {
|
||||||
/** A slow train `behind` closing on a stopped train `ahead`, both eastbound on node 1. */
|
/**
|
||||||
function rearEnder(s: GameState) {
|
* REPLACES the old rule outright (Jesse, 2026-08-29). Red Flags used to be played on a stopped
|
||||||
const node = pinned(s, 1, 'plains');
|
* train out on the Mainline and protected it from a rear-ender — measured at 4,212 offers and 4
|
||||||
node.transits.push({ tray: 'ahead', stagesRemaining: 2, stagesTotal: 2, direction: 'east' });
|
* plays across 600 games, a mechanic nobody used. ABS Signals already does that job better.
|
||||||
s.trays.set('ahead', {
|
*
|
||||||
id: 'ahead', trainNumber: 4, trainIsExtra: false, engineAt: 0,
|
* Now: "If played, asked FLAG EAST or FLAG WEST. That stops all trains from entering your limits
|
||||||
consist: [], direction: 'east', position: { at: 'mainline', index: 1 }, movesUsed: 0,
|
* from that direction (i.e. Flag East holds westbound trains)." Spent on the train it stops —
|
||||||
|
* one card, one train.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** A westbound train one Stage from entering seat 0's district from the east. */
|
||||||
|
function approaching(s: GameState) {
|
||||||
|
const officeIndex = s.division.nodes.findIndex((n) => n.kind === 'office' && n.seat === 0);
|
||||||
|
const node = pinned(s, officeIndex + 1, 'plains');
|
||||||
|
node.transits.push({ tray: 'inbound', stagesRemaining: 1, stagesTotal: 1, direction: 'west' });
|
||||||
|
s.trays.set('inbound', {
|
||||||
|
id: 'inbound', trainNumber: 9, trainIsExtra: false, engineAt: 0,
|
||||||
|
consist: [{ type: 'hopper', loaded: true }], direction: 'west',
|
||||||
|
position: { at: 'mainline', index: officeIndex + 1 }, movesUsed: 0,
|
||||||
});
|
});
|
||||||
s.trays.set('behind', {
|
|
||||||
id: 'behind', trainNumber: 2, trainIsExtra: false, engineAt: 0,
|
|
||||||
consist: [], direction: 'east', position: { at: 'divisionPoint', side: 'west' }, movesUsed: 0,
|
|
||||||
});
|
|
||||||
const dp = s.division.nodes[0];
|
|
||||||
if (dp?.kind === 'divisionPoint') dp.holding.push('behind');
|
|
||||||
s.clock.phase = 'mainline';
|
s.clock.phase = 'mainline';
|
||||||
s.movedThisPhase = new Set();
|
s.movedThisPhase = new Set();
|
||||||
return node;
|
return s.division.nodes[officeIndex] as Extract<typeof s.division.nodes[0], { kind: 'office' }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
it('holds the approaching train instead of letting it close', () => {
|
it('FLAG EAST holds a westbound train short of the Limits', () => {
|
||||||
const s = game();
|
const s = game();
|
||||||
const node = rearEnder(s);
|
const office = approaching(s);
|
||||||
node.redFlagged = ['ahead'];
|
office.redFlag = 'east';
|
||||||
|
|
||||||
advance(s);
|
advance(s);
|
||||||
assert.deepEqual(
|
const pos = s.trays.get('inbound')!.position;
|
||||||
s.trays.get('behind')!.position,
|
assert.equal(pos.at, 'mainline', 'the flagged train came in anyway');
|
||||||
{ at: 'divisionPoint', side: 'west' },
|
assert.ok(!areaOf(s, 0).adOccupancy.includes('inbound'), 'it reached an A/D track');
|
||||||
'the flagged train must not be approached',
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('comes in when the protected train rolls', () => {
|
it('is spent on the train it stops — one card, one train', () => {
|
||||||
const s = game();
|
const s = game();
|
||||||
const node = rearEnder(s);
|
const office = approaching(s);
|
||||||
node.redFlagged = ['ahead'];
|
office.redFlag = 'east';
|
||||||
// Bring the protected train to the end of its crossing so it leaves the card.
|
|
||||||
node.transits[0]!.stagesRemaining = 1;
|
|
||||||
|
|
||||||
for (let i = 0; i < 12 && (node.redFlagged?.length ?? 0) > 0; i++) advance(s);
|
advance(s);
|
||||||
assert.deepEqual(node.redFlagged, [], 'flags come in once the train moves off');
|
assert.equal(office.redFlag, undefined, 'the flag stayed up after stopping a train');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('only protects a train out on the Mainline', () => {
|
it('lets the train in on the next Mainline Phase', () => {
|
||||||
|
// "Loses one Mainline Phase" — it buys a Stage to clear the lead, not permanent protection.
|
||||||
|
const s = game();
|
||||||
|
const office = approaching(s);
|
||||||
|
office.redFlag = 'east';
|
||||||
|
advance(s);
|
||||||
|
|
||||||
|
s.clock.phase = 'mainline';
|
||||||
|
s.movedThisPhase = new Set();
|
||||||
|
advance(s);
|
||||||
|
assert.ok(areaOf(s, 0).adOccupancy.includes('inbound'), 'the train never came in');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not hold a train coming from the OTHER side', () => {
|
||||||
|
// "Flag East holds westbound trains" — an eastbound train arrives from the west.
|
||||||
|
const s = game();
|
||||||
|
const office = approaching(s);
|
||||||
|
office.redFlag = 'west';
|
||||||
|
|
||||||
|
advance(s);
|
||||||
|
assert.ok(areaOf(s, 0).adOccupancy.includes('inbound'), 'a west flag held a train from the east');
|
||||||
|
assert.equal(office.redFlag, 'west', 'the wrong-side flag was spent');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is played on a side, not on a train', () => {
|
||||||
const s = game();
|
const s = game();
|
||||||
rearEnder(s);
|
|
||||||
s.clock.phase = 'localOps';
|
s.clock.phase = 'localOps';
|
||||||
s.clock.currentActor = 0;
|
s.clock.currentActor = 0;
|
||||||
const cardId = hand(s, 'maneuver', 'redFlags');
|
const cardId = hand(s, 'maneuver', 'redFlags');
|
||||||
|
|
||||||
assert.equal(
|
assert.equal(check(s, 0, { type: 'maneuver.redFlags', cardId, side: 'east' }), null);
|
||||||
check(s, 0, { type: 'maneuver.redFlags', cardId, trayId: 'behind' }),
|
assert.equal(check(s, 0, { type: 'maneuver.redFlags', cardId, side: 'west' }), null);
|
||||||
'NO_PLACEMENT',
|
|
||||||
'a train sitting at a Division Point cannot be rear-ended',
|
|
||||||
);
|
|
||||||
assert.equal(check(s, 0, { type: 'maneuver.redFlags', cardId, trayId: 'ahead' }), null);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('will not double-flag the same train', () => {
|
it('will not double-flag the same side', () => {
|
||||||
const s = game();
|
const s = game();
|
||||||
const node = rearEnder(s);
|
|
||||||
s.clock.phase = 'localOps';
|
s.clock.phase = 'localOps';
|
||||||
s.clock.currentActor = 0;
|
s.clock.currentActor = 0;
|
||||||
const cardId = hand(s, 'maneuver', 'redFlags');
|
const cardId = hand(s, 'maneuver', 'redFlags');
|
||||||
node.redFlagged = ['ahead'];
|
const officeIndex = s.division.nodes.findIndex((n) => n.kind === 'office' && n.seat === 0);
|
||||||
|
const office = s.division.nodes[officeIndex] as { redFlag?: string };
|
||||||
|
office.redFlag = 'east';
|
||||||
|
|
||||||
assert.equal(
|
assert.equal(check(s, 0, { type: 'maneuver.redFlags', cardId, side: 'east' }), 'ALREADY_FLAGGED');
|
||||||
check(s, 0, { type: 'maneuver.redFlags', cardId, trayId: 'ahead' }),
|
assert.equal(check(s, 0, { type: 'maneuver.redFlags', cardId, side: 'west' }), null,
|
||||||
'OPTION_ALREADY_CHOSEN',
|
'the other side should still be free');
|
||||||
);
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Red Flags offered at the moment of danger (Gitea#19)', () => {
|
||||||
|
/**
|
||||||
|
* "In actual cases of danger… if there is a train or cars on the track and there will be a
|
||||||
|
* collision, then you break in with a dialog that says COLLISION RISK! FLAG AGAINST T2? This way,
|
||||||
|
* you can play the card normally or out of phase, but only if you need it."
|
||||||
|
*
|
||||||
|
* The engine establishes the danger, so the player is never asked to judge it — which is also why
|
||||||
|
* the bot can now use this card at all. It is offered ONLY to somebody holding one.
|
||||||
|
*/
|
||||||
|
function dangerous(s: GameState, giveCard: boolean) {
|
||||||
|
const officeIndex = s.division.nodes.findIndex((n) => n.kind === 'office' && n.seat === 0);
|
||||||
|
const node = pinned(s, officeIndex + 1, 'plains');
|
||||||
|
node.transits.push({ tray: 'inbound', stagesRemaining: 1, stagesTotal: 1, direction: 'west' });
|
||||||
|
s.trays.set('inbound', {
|
||||||
|
id: 'inbound', trainNumber: 9, trainIsExtra: false, engineAt: 0,
|
||||||
|
consist: [{ type: 'hopper', loaded: true }], direction: 'west',
|
||||||
|
position: { at: 'mainline', index: officeIndex + 1 }, movesUsed: 0,
|
||||||
|
});
|
||||||
|
// A hopper fouling the Running Track: §8.3 makes this arrival a collision.
|
||||||
|
const area = areaOf(s, 0);
|
||||||
|
area.grid.get(coordKey(area.officeCoord))!.standing = [{ type: 'hopper', loaded: false }];
|
||||||
|
if (giveCard) hand(s, 'maneuver', 'redFlags');
|
||||||
|
s.clock.phase = 'mainline';
|
||||||
|
s.movedThisPhase = new Set();
|
||||||
|
}
|
||||||
|
|
||||||
|
it('breaks in to offer the flag when an arrival would collide', () => {
|
||||||
|
const s = game();
|
||||||
|
dangerous(s, true);
|
||||||
|
advance(s);
|
||||||
|
assert.equal(s.clock.pendingDecision?.kind, 'redFlag', 'no prompt before a certain collision');
|
||||||
|
assert.equal(decisionActor(s), 0, 'the prompt went to the wrong player');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('flagging holds the train and costs the card', () => {
|
||||||
|
const s = game();
|
||||||
|
dangerous(s, true);
|
||||||
|
advance(s);
|
||||||
|
const before = (s.decks.hands.get(0) ?? []).length;
|
||||||
|
|
||||||
|
const r = applyIntent(s, 0, { type: 'mainline.redFlag', flag: true });
|
||||||
|
assert.ok(r.ok, 'the flag was refused');
|
||||||
|
advance(s);
|
||||||
|
|
||||||
|
assert.equal(s.players[0]!.revenue, 0, 'the collision happened anyway');
|
||||||
|
assert.equal(s.trays.get('inbound')!.position.at, 'mainline', 'the train came in regardless');
|
||||||
|
assert.equal((s.decks.hands.get(0) ?? []).length, before - 1, 'the card was not spent');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('declining lets the collision happen', () => {
|
||||||
|
const s = game();
|
||||||
|
dangerous(s, true);
|
||||||
|
advance(s);
|
||||||
|
|
||||||
|
assert.ok(applyIntent(s, 0, { type: 'mainline.redFlag', flag: false }).ok);
|
||||||
|
advance(s);
|
||||||
|
assert.equal(s.players[0]!.revenue, -5, 'waving it through did not collide');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not offer a flag to a player holding none', () => {
|
||||||
|
// A prompt with one button is not a choice, and it leaks that a collision is coming.
|
||||||
|
const s = game();
|
||||||
|
dangerous(s, false);
|
||||||
|
advance(s);
|
||||||
|
assert.equal(s.clock.pendingDecision, null, 'offered a flag to a player with no card');
|
||||||
|
assert.equal(s.players[0]!.revenue, -5, 'the collision should have happened');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stays quiet when the arrival is safe', () => {
|
||||||
|
const s = game();
|
||||||
|
dangerous(s, true);
|
||||||
|
// Clear the hazard: nothing fouling the Running Track, and room at the Office.
|
||||||
|
const area = areaOf(s, 0);
|
||||||
|
area.grid.get(coordKey(area.officeCoord))!.standing = [];
|
||||||
|
advance(s);
|
||||||
|
assert.equal(s.clock.pendingDecision, null, 'interrupted the phase for a safe arrival');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -770,6 +870,100 @@ describe('a turnout may be laid on top of a card already down', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('extras that must run loaded, and one that must not (Gitea#13)', () => {
|
||||||
|
/**
|
||||||
|
* "I've redefined some of the extra trains that they have to run full boxcars — military trains,
|
||||||
|
* circus trains, etc. If not loaded, then empty, and if none available, run without."
|
||||||
|
*
|
||||||
|
* A PREFERENCE ORDER, so every test here is about what the DIVISION YARD still holds. The rule
|
||||||
|
* has nothing to say about a train once it is running; it decides which car may be taken next.
|
||||||
|
*/
|
||||||
|
const madeUp = (trainNumber: number) => {
|
||||||
|
const s = game();
|
||||||
|
s.clock.phase = 'newTrain';
|
||||||
|
s.trays.set('t', {
|
||||||
|
id: 't', trainNumber, trainIsExtra: true, engineAt: 0,
|
||||||
|
consist: [], direction: 'east', position: { at: 'divisionPoint', side: 'west' }, movesUsed: 0,
|
||||||
|
});
|
||||||
|
return s;
|
||||||
|
};
|
||||||
|
const place = (carType: string, loaded: boolean) =>
|
||||||
|
({ type: 'newTrain.placeCar', trayId: 't', carType, loaded }) as never;
|
||||||
|
/** Leaves the Division Yard holding exactly the cars described. */
|
||||||
|
const stockYard = (s: ReturnType<typeof madeUp>, cars: { type: string; loaded: boolean }[]) => {
|
||||||
|
s.yards.divisionYard.length = 0;
|
||||||
|
s.yards.divisionYard.push(...(cars as never[]));
|
||||||
|
};
|
||||||
|
|
||||||
|
it('refuses an empty while the yard can still supply a loaded one (X18 Circus)', () => {
|
||||||
|
const s = madeUp(18);
|
||||||
|
stockYard(s, [{ type: 'boxcar', loaded: true }, { type: 'boxcar', loaded: false }]);
|
||||||
|
assert.equal(check(s, 0, place('boxcar', false)), 'NO_SUITABLE_CAR',
|
||||||
|
'an empty was accepted while a loaded boxcar was still in the yard');
|
||||||
|
assert.equal(check(s, 0, place('boxcar', true)), null, 'the loaded boxcar was refused');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts an empty once the yard has no loaded car of that kind left', () => {
|
||||||
|
// "If not loaded, then empty." The rule releases as soon as the yard cannot supply.
|
||||||
|
const s = madeUp(18);
|
||||||
|
stockYard(s, [{ type: 'boxcar', loaded: false }]);
|
||||||
|
assert.equal(check(s, 0, place('boxcar', false)), null,
|
||||||
|
'an empty was refused when the yard held no loaded car at all');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not let a loaded car of the WRONG category unlock the rule', () => {
|
||||||
|
// A loaded coach is no reason to refuse an empty boxcar: the preference is per category, since
|
||||||
|
// that is the slot the car is competing for.
|
||||||
|
const s = madeUp(18);
|
||||||
|
stockYard(s, [{ type: 'coach', loaded: true }, { type: 'boxcar', loaded: false }]);
|
||||||
|
assert.equal(check(s, 0, place('boxcar', false)), null,
|
||||||
|
'a loaded coach blocked an empty boxcar');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exempts the caboose, which is never empty in the supply', () => {
|
||||||
|
const s = madeUp(18);
|
||||||
|
stockYard(s, [{ type: 'caboose', loaded: true }, { type: 'boxcar', loaded: true }]);
|
||||||
|
assert.equal(check(s, 0, place('caboose', true)), null, 'the caboose its card calls for was refused');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies to the Military train too', () => {
|
||||||
|
const s = madeUp(19);
|
||||||
|
stockYard(s, [{ type: 'coach', loaded: true }, { type: 'coach', loaded: false }]);
|
||||||
|
assert.equal(check(s, 0, place('coach', false)), 'NO_SUITABLE_CAR',
|
||||||
|
'the Military train took an empty coach over a loaded one');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves trains without the rule alone', () => {
|
||||||
|
// X21 Freight Extra has no loading rule: an empty is as good as a loaded one.
|
||||||
|
const s = madeUp(21);
|
||||||
|
stockYard(s, [{ type: 'boxcar', loaded: true }, { type: 'boxcar', loaded: false }]);
|
||||||
|
assert.equal(check(s, 0, place('boxcar', false)), null,
|
||||||
|
'a train with no loading rule was made to prefer loaded cars');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('REGRESSION: X13 Appleseed is empties-only, and now the rules say so too', () => {
|
||||||
|
/**
|
||||||
|
* `ConsistSpec.emptiesOnly` was declared on the card, RENDERED to the player as "(empties only)"
|
||||||
|
* by `web/game.ts` and `sim/view.ts`, and enforced by NOTHING — `acceptsCar` never read it. So
|
||||||
|
* the Appleseed could be made up with loaded cars while its own card said it could not. Found
|
||||||
|
* while building Gitea#13, which is the same rule pointing the other way.
|
||||||
|
*/
|
||||||
|
const s = madeUp(13);
|
||||||
|
stockYard(s, [{ type: 'boxcar', loaded: true }, { type: 'boxcar', loaded: false }]);
|
||||||
|
assert.equal(check(s, 0, place('boxcar', true)), 'NO_SUITABLE_CAR',
|
||||||
|
'the Appleseed took a loaded car despite printing "empties only"');
|
||||||
|
assert.equal(check(s, 0, place('boxcar', false)), null, 'the Appleseed refused an empty');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still lets the Appleseed take the caboose its consist calls for', () => {
|
||||||
|
// Every caboose in ROLLING_STOCK_SUPPLY is minted loaded, so an unexempted empties-only rule
|
||||||
|
// would bar the one car the card explicitly lists.
|
||||||
|
const s = madeUp(13);
|
||||||
|
stockYard(s, [{ type: 'caboose', loaded: true }]);
|
||||||
|
assert.equal(check(s, 0, place('caboose', true)), null, 'the empties-only rule ate the caboose');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("a train is made up to its card's consist (§8.2)", () => {
|
describe("a train is made up to its card's consist (§8.2)", () => {
|
||||||
it('takes a caboose when the card calls for one, and refuses a fourth freight car', () => {
|
it('takes a caboose when the card calls for one, and refuses a fourth freight car', () => {
|
||||||
// Train 9 "Heavy Freight" is freight 3 + caboose 1. It was being made up with FOUR hoppers and
|
// Train 9 "Heavy Freight" is freight 3 + caboose 1. It was being made up with FOUR hoppers and
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
+20
-12
@@ -363,21 +363,29 @@ describe('end-of-game statistics', () => {
|
|||||||
* and so removing this line is what proves the bot has been fixed.
|
* and so removing this line is what proves the bot has been fixed.
|
||||||
*/
|
*/
|
||||||
/**
|
/**
|
||||||
* RED FLAGS JOINS IT (Gitea#3), and for the same reason — the rule is reachable and the bot will
|
* RED FLAGS JOINS IT, and the reason CHANGED with Gitea#19 — the exemption stays, but it no
|
||||||
* not take it.
|
* longer means what it used to.
|
||||||
*
|
*
|
||||||
* MEASURED over 600 games: `maneuver.redFlags` is OFFERED 4,212 times, first in game 5 — so the
|
* IT USED TO MEAN "the bot will not take it": measured over 600 games under the old rule,
|
||||||
* rule is live and constantly available. The bot PLAYS it 4 times, first in game 252. At 200
|
* `maneuver.redFlags` was OFFERED 4,212 times and PLAYED 4. The card protected a stopped train
|
||||||
* games this canary sees nothing and calls it unreachable, which is the opposite of the truth.
|
* out on the Mainline, it was always available, and the bot simply declined it.
|
||||||
*
|
*
|
||||||
* It got rarer for two compounding reasons, neither of them a broken rule: Gitea#14 took Red
|
* SINCE Gitea#19 the bot would take it every time — `worthFlagging` accepts the out-of-phase
|
||||||
* Flags from 5 copies to the sheet's 3, and Gitea#3 shortened most crossings to a single Stage,
|
* prompt unconditionally, because the engine only raises that prompt when an arrival is
|
||||||
* so the window in which a train is STANDING on a Mainline card — the only place the card may be
|
* certainly about to collide, so there is nothing left for the bot to judge. It still never
|
||||||
* played — is now usually one Stage wide.
|
* plays one. MEASURED after the redesign, 200 solitaire games: `redFlagsSet` fires ZERO times.
|
||||||
*
|
*
|
||||||
* The bot's unwillingness is the thing worth fixing, and it is in TODO.md under Bot Performance.
|
* The reason is now arithmetic rather than judgement, and it is worth writing down because it
|
||||||
* Exempted BY NAME so the other forty-odd checks stay live, and so deleting this line is what
|
* says what would actually change it. The prompt needs two things to coincide — an arrival that
|
||||||
* proves the bot has learned to use it.
|
* would collide (0.14 collisions per game, so roughly one game in seven) AND the district's
|
||||||
|
* owner holding a Red Flags card at that moment, out of a three-card hand drawn from 121. The
|
||||||
|
* bot also never plants a flag speculatively, which is the other half of the card and the half
|
||||||
|
* a human would use to buy time for switching.
|
||||||
|
*
|
||||||
|
* So this canary is measuring deck luck, not reachability. `test/mainline-cards.test.ts`
|
||||||
|
* exercises both halves of the rule end to end on a hand-built board, which is where the
|
||||||
|
* behaviour is actually pinned. Removing this line still proves something worth proving — that
|
||||||
|
* the bot has learned to plant a flag on purpose rather than only when handed one.
|
||||||
*/
|
*/
|
||||||
const KNOWN_UNREACHABLE_BY_THE_BOT = ['event flyingSwitch', 'event redFlagsSet'];
|
const KNOWN_UNREACHABLE_BY_THE_BOT = ['event flyingSwitch', 'event redFlagsSet'];
|
||||||
const found = anomalies(report.perGame);
|
const found = anomalies(report.perGame);
|
||||||
|
|||||||
+692
-84
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user