Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
441447648d | ||
|
|
603d38602c |
+139
@@ -19,6 +19,145 @@ page as `v0.1.0 · <sha> · <date>`, so what is deployed can always be identifie
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 0.7.1 — 2026-08-25
|
||||||
|
|
||||||
|
Four issues off the Gitea tracker, all of them things a player saw at the board. Two are engine or
|
||||||
|
page bugs, one is a rules ruling that supersedes a ruling from three days earlier, and the fourth
|
||||||
|
turned out not to be a bug at all — the fix there is that the game now says so. The same four ship
|
||||||
|
as **0.4.9g** on the 0.4.9 line.
|
||||||
|
|
||||||
|
### A caboose is not a load (Gitea#8)
|
||||||
|
|
||||||
|
**REPORTED:** X22 Pee-Dee could not couple a caboose — including the one it was made up with. Drop
|
||||||
|
it at the end of a sweep and it was stranded there, which makes a train whose whole card is a
|
||||||
|
restriction ("may only pick up MTs") unplayable rather than merely restricted.
|
||||||
|
|
||||||
|
`ROLLING_STOCK_SUPPLY` mints all six cabooses as `{ loaded: 6, empty: 0 }`, because §2.2's "a
|
||||||
|
coloured car is loaded, a white car is empty" is doing double duty there as a PIECE COUNT and there
|
||||||
|
is no white caboose to make a train up from. So `.loaded` carries two meanings, and the second one
|
||||||
|
escaped in exactly one place: `pickUpEmptiesOnly`'s `fresh.some((c) => c.loaded)`. Every other read
|
||||||
|
of the flag in `apply.ts` is already scoped to a coach or to a named car type.
|
||||||
|
|
||||||
|
`carriesLoad` now answers the question the rule is actually asking — a caboose carries the crew, not
|
||||||
|
freight, so it is never a load — and the restriction itself is untouched: a loaded car alongside the
|
||||||
|
caboose still refuses. `trainRules` says so on the card ("A caboose is not a load"), because a player
|
||||||
|
reading "EMPTIES ONLY" has no way to know which reading the game took.
|
||||||
|
|
||||||
|
### The Day rolls over and says so (Gitea#10)
|
||||||
|
|
||||||
|
**REPORTED:** "As the game rolls off the end of the day, you get a dialog saying such. Hard to keep
|
||||||
|
track of time."
|
||||||
|
|
||||||
|
Nothing on screen was wrong. The clock, the turn chart and the timetable all said which Day it was.
|
||||||
|
What is wrong is WHEN it changes: a Day turns over inside the phases that run themselves, so it
|
||||||
|
happens between one click and the next, while the player is watching the board and waiting for their
|
||||||
|
turn. The two transient signals the page already had are both gone before that — the phase banner at
|
||||||
|
2.6s, the announcement flash at 4.2s. A modal is the whole request: it stops, and it waits.
|
||||||
|
|
||||||
|
`dayEndHtml` writes it from the frame AFTER the rollover, so the Day that ended is `f.day - 1`.
|
||||||
|
It carries the standings in **Revenue order** rather than seat order (the question at the end of a
|
||||||
|
Day is who is ahead), the Days left to run, and the combined target — reported against the whole
|
||||||
|
table's Revenue, because `minCombinedRevenue` is a combined floor and one player's score against a
|
||||||
|
four-player target reads as hopeless when the table is comfortably ahead. Collisions appear only
|
||||||
|
where §3.4 actually scores them: competitive and co-op, and only when a dial is non-zero. A solitaire
|
||||||
|
game carries the default dials and enforces neither, so printing a collision budget there would put a
|
||||||
|
rule on screen that this game does not have.
|
||||||
|
|
||||||
|
Three suppressions, each of them a way it would otherwise lie:
|
||||||
|
|
||||||
|
- **the first frame** — arriving in a game already on Day 3 is not Day 2 ending, and a page reloaded
|
||||||
|
mid-game would announce a rollover that happened before it was watching;
|
||||||
|
- **the Day going DOWN** — that is Undo stepping back across the rollover, not a Day passing. Undo
|
||||||
|
also clears `lastDay`, so replaying forward through the same rollover does not announce it twice;
|
||||||
|
- **the Day the game ends on** — the outcome panel is the thing to read then. Verified by playing a
|
||||||
|
full solitaire game through: the dialog fires four times in a 5-Day game, not five.
|
||||||
|
|
||||||
|
### A Timetabled train may be thrown away (Gitea#9)
|
||||||
|
|
||||||
|
**REPORTED:** "Timetabled trains are at the choice of the player — they can either play or discard.
|
||||||
|
If someone else wants to pick it up, they are more than able to. The reason: I don't want, if you
|
||||||
|
decide to play a game longer than five days, to decide that maybe there are too many trains, the
|
||||||
|
stations are jammed, and the railroad doesn't need any more. You can toss it. Someone else might
|
||||||
|
disagree and pick it up."
|
||||||
|
|
||||||
|
This **supersedes Gitea#6**, shipped three days earlier in 0.6.2, which made every train card
|
||||||
|
unconditionally undiscardable. Two things narrow it:
|
||||||
|
|
||||||
|
- an **Extra** is still never discardable. It never joins the timetable, so it can never be what
|
||||||
|
jams it, and the only rule it would dodge by being thrown away is the hand limit;
|
||||||
|
- the Timetabled half is a **New Game setting** — `discardTimetabled`, on by default — because
|
||||||
|
Jesse's reasoning is explicitly about games run LONGER than five days, and a five-Day game may well
|
||||||
|
want Gitea#6's pressure back. Jesse asked for it as a setting on this line and as the plain rule on
|
||||||
|
the 0.4.9 line, which has no scaffolding for one; both play the same game at main's default.
|
||||||
|
|
||||||
|
**The second half of the ruling needed no code at all.** "Someone else might pick it up" — a discard
|
||||||
|
already goes face-up onto a Department pile, and a Department pile is exactly what a rival draws
|
||||||
|
from. Only the first half was a change.
|
||||||
|
|
||||||
|
**One place decides, and the card says which rule refused.** `keepReason` returns the sentence a
|
||||||
|
player should read, or `null` if the card may go; `check`, the hand panel and the blocked "End Local
|
||||||
|
Operations" button all ask it. It returns a SENTENCE rather than a boolean because there are now two
|
||||||
|
distinct reasons — "an Extra is never discarded" and "not in this game" — and the panel that used to
|
||||||
|
hard-code one of them would now tell half the players the wrong thing. It reaches the page as the
|
||||||
|
Frame's `handKeepWhy`, replacing text `panels.ts` and `main.ts` each wrote for themselves.
|
||||||
|
|
||||||
|
**Two places would have dropped the setting silently**, both found by looking rather than by `tsc` —
|
||||||
|
`HouseRuleOverrides`' fields are all optional, so omitting one compiles and falls back to the
|
||||||
|
default. The New Game dialog's close handler builds its own `houseRules` object (the comment
|
||||||
|
directly above it warns of exactly this: "a setting missing from here is a setting the dialog
|
||||||
|
silently discards"), and `presets.ts`'s Frame-to-config path, which is how a JOINER is shown someone
|
||||||
|
else's game — a setting dropped there shows them a rule the table is not playing.
|
||||||
|
|
||||||
|
**Gitea#6's corner survives, narrowed.** A hand of four undiscardable trains still has exactly one
|
||||||
|
legal way on — play one — with nothing in the engine computing "you must play a train". With the
|
||||||
|
setting on, the only hand that reaches it is four Extras; with it off, any four trains, as before.
|
||||||
|
Both are pinned by tests.
|
||||||
|
|
||||||
|
### Why nothing is moving on the platform (Gitea#2)
|
||||||
|
|
||||||
|
**REPORTED:** "The Sparrow pulled into the station with two loaded coaches. There are two passengers
|
||||||
|
on the platform. Four porters. My thought was to unload two and load two. I never get the chance to
|
||||||
|
load the last two."
|
||||||
|
|
||||||
|
**The engine is not deviating from the rules**, and this was checked step by step against the
|
||||||
|
reported save. §9.2 discards the white coach into the Classification Yard on boarding and draws one
|
||||||
|
from the Division Yard on de-training; §2.2 returns the Classification Yard only when the Division
|
||||||
|
Yard is empty. All three are implemented exactly. What bites is the interaction: both halves of every
|
||||||
|
passenger cycle consume coaches one-way, and a single global refill condition over a pile of six
|
||||||
|
commodities means they do not come back. Traced over the reported game the coach pool goes 8+/8− on
|
||||||
|
Day 1 to 0+/1− by Day 5, with eight coaches stranded in Classification behind ~60 other cars.
|
||||||
|
|
||||||
|
**Jesse's ruling is that the shortage stays** — "it is possible to run out, that's part of the
|
||||||
|
strategy" — so the three balance options written up in `TODO.md` are declined rather than deferred.
|
||||||
|
|
||||||
|
What was unambiguously a bug is that the game said **nothing**. A Porter action that cannot be taken
|
||||||
|
is simply absent from the menu, and `impediments()` — the panel whose entire job is "why is nothing
|
||||||
|
moving?" — opened with `if (!f || f.kind !== 'freight') continue`, so a platform had never had
|
||||||
|
anything to say for itself. The player was not merely blocked; he was given no reason.
|
||||||
|
|
||||||
|
A Passenger Facility now reports both directions: passengers standing with no train to take them, a
|
||||||
|
train whose card bars Porters from working it, a Terminals-only train at a lesser Office, every coach
|
||||||
|
already full, the red slots full, the same-district rule, and the coach shortage itself — that last
|
||||||
|
one naming how many coaches are sitting in Classification and the condition that brings them back,
|
||||||
|
because a yard visibly full of cars that will not yield one coach is the state that looks like a
|
||||||
|
broken game. The reason text comes from `passengerRefusal`, the engine's own predicate (exported for
|
||||||
|
this), so what is on screen is the rule that actually refused rather than a second guess at it.
|
||||||
|
|
||||||
|
**A second defect fell out of fixing it.** The row's name is read off `card.geometry.facility`, which
|
||||||
|
a Passenger Facility does not have — it rides on the `office` card — so every passenger impediment
|
||||||
|
would have read `facility 0,0` beside a freight row saying `mineTipple 1,-3`. It is named by the
|
||||||
|
Office Area's tier now (`terminal 0,0`), and there is exactly one Office per Area, so that tier is
|
||||||
|
the card's own.
|
||||||
|
|
||||||
|
Verified by replaying the reported save (`playtests/station-master-seed947338225-day5(1).json`)
|
||||||
|
through `fromSave` and printing the panel. Worth noting for anyone who tries it: that save is a
|
||||||
|
v0.4.9-line recording and stops at intent 250 of 323 on the 0.7 engine, because Gitea#4, #6 and #7
|
||||||
|
changed the rules its later intents were recorded against. That is expected divergence, not a
|
||||||
|
save-format bug — a replay reproduces a game from decisions, and the decisions no longer mean the
|
||||||
|
same thing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 0.7.0 — 2026-08-23
|
## 0.7.0 — 2026-08-23
|
||||||
|
|
||||||
The multiplayer set-up, the lobby, the start of a game, and four things a remote client had never
|
The multiplayer set-up, the lobby, the start of a game, and four things a remote client had never
|
||||||
|
|||||||
@@ -69,12 +69,40 @@ Queued 2026-08-22, from the v0.4.9e gameplay-testing report filed as Gitea issue
|
|||||||
and Extra alike; the forced play falls out of the hand limit rather than needing a mechanism of
|
and Extra alike; the forced play falls out of the hand limit rather than needing a mechanism of
|
||||||
its own. Reasoning in `docs/rules/implications.md` §6.2.
|
its own. Reasoning in `docs/rules/implications.md` §6.2.
|
||||||
|
|
||||||
12b. **Gitea#2 — four porters, two passengers on the platform, and only one may be worked.**
|
12b. ~~**Gitea#2 — four porters, two passengers on the platform, and only one may be worked**~~ —
|
||||||
DIAGNOSED, AWAITING JESSE'S RULING — see Play Balance below. The engine is faithful to the
|
RULED AND FIXED in the release below, though not the way the report implies. The engine is
|
||||||
written rules at every step; what bites is that BOTH directions of porter work move coaches
|
faithful to the written rules at every step; what bites is that BOTH directions of porter work
|
||||||
one-way into a Classification Yard that comes back only when the Division Yard is bare of all
|
move coaches one-way into a Classification Yard that comes back only when the Division Yard is
|
||||||
~60 cars. Sixteen coaches in the game, and the reported save runs dry on Day 5 with eight of
|
bare of all ~60 cars. Sixteen coaches in the game, and the reported save runs dry on Day 5 with
|
||||||
them stranded in Classification.
|
eight of them stranded in Classification. **Jesse's ruling: the shortage stays** — "it is
|
||||||
|
possible to run out, that's part of the strategy" — so the three balance options in Play Balance
|
||||||
|
below are declined, not deferred. What was actually wrong is that the game said NOTHING: a Porter
|
||||||
|
action that cannot be taken is simply absent from the menu, and the "why is nothing moving?"
|
||||||
|
panel covered freight facilities only. That half is fixed.
|
||||||
|
|
||||||
|
12e. ~~**Gitea#8 — the per-diem train could not couple a caboose**~~ — done in the release below.
|
||||||
|
`ROLLING_STOCK_SUPPLY` mints all six cabooses `loaded: true` because §2.2's "coloured is loaded,
|
||||||
|
white is empty" doubles as a PIECE COUNT there and there is no white caboose. X22 Pee-Dee, whose
|
||||||
|
whole card is "may only pick up MTs", read that literally and refused every caboose including the
|
||||||
|
one it was made up with — set it out and the train was stranded, which made it unplayable rather
|
||||||
|
than merely restricted. A caboose carries the crew, not freight, so it is never a load.
|
||||||
|
|
||||||
|
12g. ~~**Gitea#9 — a Timetabled train may be discarded**~~ — done in the release below, and it
|
||||||
|
SUPERSEDES Gitea#6 (item 12c above), shipped three days earlier. A Timetabled train may be tossed
|
||||||
|
face-up to a Department slot, where a rival may pick it up — which needed no machinery, since
|
||||||
|
that is where every discard already goes. An Extra still may not: it never joins the timetable,
|
||||||
|
so it can never be what jams it. On this line the Timetabled half is a New Game setting
|
||||||
|
(`discardTimetabled`, on by default), because Jesse's reasoning is about games run longer than
|
||||||
|
five Days; the 0.4.9 line takes the plain rule. Reasoning in `docs/rules/implications.md` §6.2.
|
||||||
|
|
||||||
|
12f. ~~**Gitea#10 — a dialog when the Day rolls over**~~ — done in the release below. "Hard to keep
|
||||||
|
track of time." Nothing on screen was wrong — the clock, the turn chart and the timetable all
|
||||||
|
said which Day it was — but a Day turns over inside the phases that run themselves, so it passes
|
||||||
|
between one click and the next, and the two transient signals the page had (the phase banner at
|
||||||
|
2.6s, the announcement flash at 4.2s) are both gone before a player reading the board notices.
|
||||||
|
A modal stops and waits, and carries the standings, the Days left and the combined target.
|
||||||
|
Suppressed on the first frame, on Undo stepping back across a rollover, and on the Day the game
|
||||||
|
ends — the outcome panel is the thing to read then.
|
||||||
|
|
||||||
13. **Show me the other players' moves, bots included** — raised by Jesse 2026-08-22 from playing a
|
13. **Show me the other players' moves, bots included** — raised by Jesse 2026-08-22 from playing a
|
||||||
multiplayer game. Reasoning in Multiplayer below.
|
multiplayer game. Reasoning in Multiplayer below.
|
||||||
@@ -130,6 +158,29 @@ pass as 19-21 and 24 above, and he asked for them to be discussed together rathe
|
|||||||
29. **Put the Fedora at the right-hand end of the phase row**, with (or in) the Supervisor Shift
|
29. **Put the Fedora at the right-hand end of the phase row**, with (or in) the Supervisor Shift
|
||||||
pill. Reasoning in Display below.
|
pill. Reasoning in Display below.
|
||||||
|
|
||||||
|
Queued 2026-08-25, from releasing 0.7.1 / 0.4.9g. **Both are blocked on the two commits being made
|
||||||
|
and pushed** — they were still uncommitted when the session ended.
|
||||||
|
|
||||||
|
30. **Close the Gitea issues by hand, each with a comment naming the commit that fixed it.** Raised
|
||||||
|
by Jesse 2026-08-25. `Closes #<n>` is in the main commit message and will auto-close #2, #8, #9
|
||||||
|
and #10 when it reaches the default branch, but auto-closing leaves the issue with no record of
|
||||||
|
WHICH commit or WHICH release answered it — and #9 supersedes #6, which nothing on #6 will ever
|
||||||
|
say. So each issue wants a comment of its own: the commit SHA on both lines, the version each
|
||||||
|
shipped in (0.7.1 and 0.4.9g), and for #2 and #9 the ruling that decided it, since neither was
|
||||||
|
fixed the way the report implies. #6 wants a comment too, pointing at #9. The token and the API
|
||||||
|
calls are in the workspace's `AGENTS.local.md`.
|
||||||
|
|
||||||
|
31. **Bump the StartOS wrapper to 0.7.1.** `station-master-startos` is clean, on `master`, pinned to
|
||||||
|
`v0.7.0` and versioned `0.7.0:3`. Once v0.7.1 is tagged: move the submodule pin to the tag, set
|
||||||
|
`startos/versions/current.ts` to **`0.7.1:0`** (a new upstream tag resets the downstream digit —
|
||||||
|
`UPDATING.md`), and write release notes in all five locales. **No new version FILE and no
|
||||||
|
migration** — `current.ts`'s `migrations.up` is empty, so nothing is carried from 0.7.0, and a
|
||||||
|
0.7.0 save replays under 0.7.1 unchanged (its intents cannot contain a Timetabled discard,
|
||||||
|
because that was illegal when they were recorded). `instructions.md` and `README.md` both name
|
||||||
|
0.7.0 and both need the three user-visible changes: the Day-end dialog, the Timetabled discard
|
||||||
|
and its New Game checkbox, and the reasons a blocked platform now gives. Remember the branch
|
||||||
|
trap: the game pushes to `main`, the wrapper to `master`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Replay / Save Games
|
## Replay / Save Games
|
||||||
@@ -466,19 +517,27 @@ number until the rules stop moving.
|
|||||||
~60 cars) are consumed by both halves of every passenger cycle. Traced over the reported game
|
~60 cars) are consumed by both halves of every passenger cycle. Traced over the reported game
|
||||||
the coach pool goes 8+/8− to 0+/1− by Day 5.
|
the coach pool goes 8+/8− to 0+/1− by Day 5.
|
||||||
|
|
||||||
**Three ways out, and it is Jesse's call which:** (a) refill when the Division Yard is dry of
|
**RULED — the shortage stays, and none of the three is being built.** Jesse: "it is possible
|
||||||
the type-and-state being asked for rather than dry of everything — the reading a player
|
to run out, that's part of the strategy." For the record, the options were (a) refill when the
|
||||||
rummaging a table-top pile actually uses, and the biggest balance change; (b) the same trigger
|
Division Yard is dry of the type-and-state being asked for rather than dry of everything; (b)
|
||||||
but return only the cars of that type; (c) leave the rules alone and raise the coach count in
|
the same trigger but return only the cars of that type; (c) leave the rules alone and raise the
|
||||||
`ROLLING_STOCK_SUPPLY`, which the item below already sanctions — lowest risk, but it delays the
|
coach count in `ROLLING_STOCK_SUPPLY`. All three are declined. What shipped instead is the
|
||||||
wall rather than removing it. Measure (a) or (b) over 400 paired seeds before shipping.
|
EXPLANATION — the impediments panel now says the Division Yard has no white coach, how many are
|
||||||
|
stranded in Classification, and that Classification returns only when the Division Yard is
|
||||||
|
bare. Running dry is a position to play out of, not a broken game, once the screen says so.
|
||||||
|
|
||||||
- [ ] **A blocked PASSENGER facility produces no impediment at all.** `impediments()`
|
- [x] **A blocked PASSENGER facility produces no impediment at all — FIXED.** `impediments()`
|
||||||
(`src/sim/narrate.ts`) opens with `if (!f || f.kind !== 'freight') continue`, so the "why
|
(`src/sim/narrate.ts`) opened with `if (!f || f.kind !== 'freight') continue`, so the "why
|
||||||
nothing is moving" panel has never had anything to say about a platform. That is the second
|
nothing is moving" panel had never had anything to say about a platform. That was the second
|
||||||
half of Gitea#2 and the half that is unambiguously a bug: the player above was not merely
|
half of Gitea#2 and the half that was unambiguously a bug: the player above was not merely
|
||||||
blocked, he was given no reason — the button simply was not there. Worth fixing whichever way
|
blocked, he was given no reason — the button simply was not there. A platform now reports
|
||||||
the supply question is settled.
|
passengers with no train, a train the card bars Porters from working, full coaches, full red
|
||||||
|
slots, the same-district rule, and the coach shortage itself — the last naming how many coaches
|
||||||
|
are stranded in Classification and what brings them back. The reason comes from
|
||||||
|
`passengerRefusal`, the engine's own predicate, so the panel cannot drift from the rule that
|
||||||
|
actually refused. Fixing the label found a second defect: a Passenger Facility rides on the
|
||||||
|
`office` card, so every passenger row would have read `facility 0,0` next to `mineTipple 1,-3`;
|
||||||
|
it is named by its tier now.
|
||||||
|
|
||||||
- [ ] **The log lowercases the first letter of every narration it attributes to a player**, so
|
- [ ] **The log lowercases the first letter of every narration it attributes to a player**, so
|
||||||
`EXTRA X18 started…` renders as `Player Solitaire eXTRA X18 started…` (`src/web/game.ts`:1065,
|
`EXTRA X18 started…` renders as `Player Solitaire eXTRA X18 started…` (`src/web/game.ts`:1065,
|
||||||
@@ -1511,6 +1570,26 @@ What is on the screen and where. Split out of Other 2026-08-22; the rules are el
|
|||||||
|
|
||||||
Doesn't fit the above.
|
Doesn't fit the above.
|
||||||
|
|
||||||
|
- [ ] **THE 0.4.9 PLAYTEST LINE IS BEHIND ON A RULES RULING, and that was checked rather than
|
||||||
|
assumed.** Recorded 2026-08-23, when Jesse asked whether any of v0.7.0 needed porting to the
|
||||||
|
`playtest` branch. Almost none of it does — that line has no lobby and no server, and the
|
||||||
|
`undo()` config fix is inert there because its `GameConfig` carries no victory dials, so
|
||||||
|
`configWith` only ever varies the house rules the save already restores.
|
||||||
|
|
||||||
|
**But one v0.5.0 ruling is a CODE difference the testers do not have.** §A.4, the Local's
|
||||||
|
coach: `main`'s `apply.ts` reads `if (dropRules.coachStaysOnStationTrack && cut.some(coach) &&
|
||||||
|
!atOffice)`, and `playtest`'s is the same line **without `&& !atOffice`** — so on that build a
|
||||||
|
coach still may not be set out at the Office. The other two questions the 0.4.9 README calls
|
||||||
|
open are documentation-only there (Poling is already at 0 copies; Heavy Grade behaves
|
||||||
|
identically and even deals the Mainline deck without replacement, so the correction applies
|
||||||
|
word for word).
|
||||||
|
|
||||||
|
**Jesse's call, 2026-08-23: do not port it now.** A settled rules change is not a playtest bug
|
||||||
|
fix, and pushing one into the build people are mid-playtest on would invalidate the feedback
|
||||||
|
that build exists to collect. Recorded so the divergence is a decision rather than a surprise —
|
||||||
|
and so the 0.4.9 README is not "corrected" to match `main`'s wording, which would then describe
|
||||||
|
behaviour that build does not have.
|
||||||
|
|
||||||
- [ ] **Documentation generated from the implementation, not written alongside it.** Raised by Jesse
|
- [ ] **Documentation generated from the implementation, not written alongside it.** Raised by Jesse
|
||||||
2026-08-22, immediately after Gitea#7 changed the coach counts on four train cards and the
|
2026-08-22, immediately after Gitea#7 changed the coach counts on four train cards and the
|
||||||
answer to "where do we keep track of that?" turned out to be **five places of three different
|
answer to "where do we keep track of that?" turned out to be **five places of three different
|
||||||
|
|||||||
+47
-16
@@ -888,34 +888,65 @@ any setting** — being a place an Extra can start is part of what upgrading buy
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## §6.2 — a train card is never discarded
|
## §6.2 — which train cards may be discarded
|
||||||
|
|
||||||
**Jesse's ruling, v0.4.9e playtest** (Gitea#6): "Players are not allowed to discard Train cards. They
|
**SUPERSEDED ONCE. Read both rulings; the second narrows the first.**
|
||||||
may keep the card in their hand for multiple stages and even multiple days, but they may not discard
|
|
||||||
it. If a player has three train cards in their hand, and they draw a fourth, then they must play one
|
|
||||||
of those cards."
|
|
||||||
|
|
||||||
**Extras count.** An Extra is a train, even though it runs once and ends in the Salvage Yard where a
|
**Gitea#6, v0.4.9e playtest:** "Players are not allowed to discard Train cards. They may keep the
|
||||||
Timetabled card joins the timetable for the rest of the game.
|
card in their hand for multiple stages and even multiple days, but they may not discard it. If a
|
||||||
|
player has three train cards in their hand, and they draw a fourth, then they must play one of those
|
||||||
|
cards." Extras counted: an Extra is a train.
|
||||||
|
|
||||||
|
**Gitea#9, 2026-08-24 — the ruling in force:** "Timetabled trains are at the choice of the player:
|
||||||
|
they can either play or discard. If someone else wants to pick it up, they are more than able to.
|
||||||
|
The reason: I don't want, if you decide to play a game longer than five days, to decide that maybe
|
||||||
|
there are too many trains, the stations are jammed, and the railroad doesn't need any more. You can
|
||||||
|
toss it. Someone else might disagree and pick it up."
|
||||||
|
|
||||||
|
So the rule is now:
|
||||||
|
|
||||||
|
- a **Timetabled** train may be discarded;
|
||||||
|
- an **Extra** may not. It never joins the timetable, so it can never be what jams it, and the only
|
||||||
|
rule it would dodge by being thrown away is the hand limit;
|
||||||
|
- **on `main` the Timetabled half is a New Game setting** (`discardTimetabled`, on by default),
|
||||||
|
because Jesse's reasoning is explicitly about LONG games and a five-Day game may well want
|
||||||
|
Gitea#6's pressure. The 0.4.9 playtest line has no scaffolding for a setting and takes the plain
|
||||||
|
rule. Both lines behave identically at their defaults.
|
||||||
|
|
||||||
|
**"Someone else might disagree and pick it up" needed no machinery.** A discard already goes face-up
|
||||||
|
onto a Department pile, and a Department pile is exactly what a rival draws from. The second half of
|
||||||
|
the ruling was already built; only the first half was a change.
|
||||||
|
|
||||||
§6.2 as transcribed says only "the player must reduce his hand to no more than three cards" with no
|
§6.2 as transcribed says only "the player must reduce his hand to no more than three cards" with no
|
||||||
exception for any card type, so this is a ruling rather than a gap — the prototype rules do not
|
exception for any card type, so both of these are rulings rather than gaps — the prototype rules do
|
||||||
address it either way.
|
not address it either way.
|
||||||
|
|
||||||
### It needs no forcing mechanism, and that is the point
|
### It needs no forcing mechanism, and that is the point
|
||||||
|
|
||||||
The interesting property of this rule is that the forced play falls out of two rules that already
|
The interesting property of the rule is that the forced play falls out of two rules that already
|
||||||
exist rather than needing a third:
|
exist rather than needing a third:
|
||||||
|
|
||||||
1. a train card cannot be discarded, so it is not among the ways to shed a card; and
|
1. an undiscardable card is not among the ways to shed a card; and
|
||||||
2. `draw.end` already refuses while the hand is over the limit (§6.2).
|
2. `draw.end` already refuses while the hand is over the limit (§6.2).
|
||||||
|
|
||||||
A player holding four trains therefore has exactly one legal way to conclude the turn — play one —
|
A player holding four undiscardable trains therefore has exactly one legal way to conclude the turn —
|
||||||
without anything in the engine ever computing "you must play a train". The corner cannot lock a
|
play one — without anything in the engine ever computing "you must play a train". The corner cannot
|
||||||
player in, because **playing a train card is unconditionally legal**: `card.play`'s train case
|
lock a player in, because **playing a train card is unconditionally legal**: `card.play`'s train case
|
||||||
refuses only a board placement, and a train card played when the timetable is full still leaves the
|
refuses only a board placement, and a train card played when the timetable is full still leaves the
|
||||||
hand (it simply schedules nothing). Confirmed by playing it: a hand of four trains offers zero
|
hand (it simply schedules nothing). Confirmed by playing it: such a hand offers zero discards, no
|
||||||
discards, no `draw.end`, and four plays.
|
`draw.end`, and four plays.
|
||||||
|
|
||||||
|
**Gitea#9 does not retire that corner, it narrows the way in.** With the setting on, the only hand
|
||||||
|
that reaches it is four Extras; with the setting off it is any four trains, exactly as before.
|
||||||
|
|
||||||
|
### One place decides, and the card says which rule refused
|
||||||
|
|
||||||
|
`keepReason` (`src/engine/apply.ts`) returns the sentence a player should read, or `null` if the card
|
||||||
|
may be discarded. `check`, the hand panel and the blocked "End Local Operations" button all ask it,
|
||||||
|
so none of them can drift from the rule. It returns a SENTENCE rather than a boolean because there
|
||||||
|
are now two distinct reasons — "an Extra is never discarded" and "not in this game" — and a panel
|
||||||
|
that hard-codes one of them tells half the players the wrong thing. It reaches the page as the
|
||||||
|
Frame's `handKeepWhy`.
|
||||||
|
|
||||||
The bot needed no rule of its own either. `legal.ts` enumerates candidates and filters them through
|
The bot needed no rule of its own either. `legal.ts` enumerates candidates and filters them through
|
||||||
`check`, so the option stops being offered; and the developer bot already reaches for `card.play`
|
`check`, so the option stops being offered; and the developer bot already reaches for `card.play`
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "station-master",
|
"name": "station-master",
|
||||||
"version": "0.7.0",
|
"version": "0.7.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Station Master — a railroad operations game",
|
"description": "Station Master — a railroad operations game",
|
||||||
|
|||||||
+61
-19
@@ -417,14 +417,42 @@ export function canDetrain(s: GameState, player: PlayerIndex, at: GridCoord, tra
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A Timetabled or Extra train card (§6.2, Gitea#6) — the one place that decides what "a train card"
|
* A Timetabled or Extra train card (§6.2) — the one place that decides what "a train card" means.
|
||||||
* means, so the rule, the UI's reason text and any test all ask the same question.
|
|
||||||
*/
|
*/
|
||||||
export function isTrainCard(s: GameState, cardId: CardId): boolean {
|
export function isTrainCard(s: GameState, cardId: CardId): boolean {
|
||||||
const kind = s.cards.get(cardId)?.kind.kind;
|
const kind = s.cards.get(cardId)?.kind.kind;
|
||||||
return kind === 'timetabledTrain' || kind === 'extraTrain';
|
return kind === 'timetabledTrain' || kind === 'extraTrain';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WHY THIS CARD CANNOT BE THROWN AWAY, or `null` if it can (§6.2, Gitea#9 superseding Gitea#6).
|
||||||
|
*
|
||||||
|
* The one place that answers the question, so `check`, the hand panel and the blocked "End Local
|
||||||
|
* Operations" button all give the same reason rather than three hand-written approximations of it.
|
||||||
|
* Gitea#6 made every train card unconditionally undiscardable; Gitea#9 narrows that:
|
||||||
|
*
|
||||||
|
* - a TIMETABLED train is discardable unless the `discardTimetabled` house rule is off. Jesse's
|
||||||
|
* reasoning is about a long game whose timetable has filled up — "the stations are jammed and
|
||||||
|
* the railroad doesn't need any more. You can toss it";
|
||||||
|
* - an EXTRA is never discardable. It never joins the timetable, so it cannot jam it, and the
|
||||||
|
* rule it would otherwise dodge is the hand limit.
|
||||||
|
*
|
||||||
|
* Returns the sentence rather than a code because it is written for a player, and the two cases
|
||||||
|
* fail for genuinely different reasons — "not in this game" and "not ever".
|
||||||
|
*/
|
||||||
|
export function keepReason(s: GameState, cardId: CardId): string | null {
|
||||||
|
const kind = s.cards.get(cardId)?.kind.kind;
|
||||||
|
if (kind === 'extraTrain') {
|
||||||
|
return 'An Extra is never discarded. It runs once and ends in the Salvage Yard, so it can only ' +
|
||||||
|
'be played — hold it for as many Stages and Days as you like.';
|
||||||
|
}
|
||||||
|
if (kind === 'timetabledTrain' && !houseRules(s.config).discardTimetabled) {
|
||||||
|
return 'A train card is never discarded in this game. The only way it leaves your hand is onto ' +
|
||||||
|
'the timetable — hold it for as many Stages and Days as you like.';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* WHERE AN EXTRA STARTS AND WHICH WAY IT RUNS — the one answer `check`, `execute` and the reducer
|
* WHERE AN EXTRA STARTS AND WHICH WAY IT RUNS — the one answer `check`, `execute` and the reducer
|
||||||
* all use, so a placement can never be checked against one square and made on another.
|
* all use, so a placement can never be checked against one square and made on another.
|
||||||
@@ -563,6 +591,21 @@ function freightWorkedKey(trayId: TrayId, at: GridCoord): string {
|
|||||||
|
|
||||||
const isFreight = (c: RollingStock): boolean => c.type !== 'coach' && c.type !== 'caboose';
|
const isFreight = (c: RollingStock): boolean => c.type !== 'coach' && c.type !== 'caboose';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* IS THIS CAR CARRYING A LOAD? A CABOOSE NEVER IS, whatever its `loaded` flag says.
|
||||||
|
*
|
||||||
|
* `ROLLING_STOCK_SUPPLY` mints all six cabooses as `{ loaded: 6, empty: 0 }` because §2.2's
|
||||||
|
* "a coloured car is loaded, a white car is empty" is doing double duty there as a PIECE COUNT,
|
||||||
|
* and a caboose has no white version — there is no such thing as an empty one to make up a train
|
||||||
|
* from. Every other reading of `.loaded` in this file is already scoped to a coach or to a named
|
||||||
|
* car type, so the flag's second meaning only ever escaped here.
|
||||||
|
*
|
||||||
|
* Reported as Gitea#8: X22 Pee-Dee, whose whole card is "may only pick up MTs", could not couple a
|
||||||
|
* caboose at all — including the one it was made up with. Drop it and it was stranded, which made
|
||||||
|
* the train unplayable rather than merely restricted.
|
||||||
|
*/
|
||||||
|
const carriesLoad = (c: RollingStock): boolean => c.loaded && c.type !== 'caboose';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* May this train work these freight cars on this square?
|
* May this train work these freight cars on this square?
|
||||||
*
|
*
|
||||||
@@ -662,7 +705,7 @@ function refusesThisOffice(s: GameState, player: PlayerIndex, tray: CrewTray): b
|
|||||||
* this works out whether a card is the reason. Without it a Military train standing at the platform
|
* this works out whether a card is the reason. Without it a Military train standing at the platform
|
||||||
* reported "no train at the Office", which is both wrong and unhelpful.
|
* reported "no train at the Office", which is both wrong and unhelpful.
|
||||||
*/
|
*/
|
||||||
function passengerRefusal(
|
export function passengerRefusal(
|
||||||
s: GameState,
|
s: GameState,
|
||||||
player: PlayerIndex,
|
player: PlayerIndex,
|
||||||
at: GridCoord,
|
at: GridCoord,
|
||||||
@@ -777,7 +820,7 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
|
|||||||
*/
|
*/
|
||||||
if (rules.noSwitching) return 'PICKUP_NOT_ALLOWED';
|
if (rules.noSwitching) return 'PICKUP_NOT_ALLOWED';
|
||||||
if (rules.dropOnly) return 'PICKUP_NOT_ALLOWED';
|
if (rules.dropOnly) return 'PICKUP_NOT_ALLOWED';
|
||||||
if (rules.pickUpEmptiesOnly && fresh.some((c) => c.loaded)) return 'EMPTIES_ONLY';
|
if (rules.pickUpEmptiesOnly && fresh.some(carriesLoad)) return 'EMPTIES_ONLY';
|
||||||
const freight = fresh.filter(isFreight).length;
|
const freight = fresh.filter(isFreight).length;
|
||||||
if (freight > 0 && !freightBudgetLeft(s, player, tray, i.to, freight)) return 'FREIGHT_WORKED_HERE';
|
if (freight > 0 && !freightBudgetLeft(s, player, tray, i.to, freight)) return 'FREIGHT_WORKED_HERE';
|
||||||
}
|
}
|
||||||
@@ -881,30 +924,29 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* §6.2, AS RULED BY JESSE (Gitea#6): A TRAIN CARD MAY NOT BE DISCARDED. EVER.
|
* §6.2 — WHICH TRAIN CARDS MAY BE THROWN AWAY (Gitea#9, superseding Gitea#6).
|
||||||
*
|
*
|
||||||
* It may be held for as many Stages and Days as the player likes — the hand limit is the only
|
* `keepReason` holds the rule; this asks it. A Timetabled train is discardable unless the
|
||||||
* pressure on it — but it never goes onto a Department pile. The consequence is the point of the
|
* `discardTimetabled` house rule is off, and an Extra never is.
|
||||||
* rule and needs no machinery of its own: a player holding four train cards has nothing
|
|
||||||
* discardable, and `draw.end` already refuses while the hand is over the limit, so the only way
|
|
||||||
* to conclude the turn is to PLAY one. Playing a train card is unconditionally legal (see
|
|
||||||
* `card.play`'s `timetabledTrain` case, which refuses only a board placement), so that corner
|
|
||||||
* can never lock a player in.
|
|
||||||
*
|
*
|
||||||
* Extras count. They are trains — Jesse's ruling in the same breath — even though an Extra runs
|
* WHERE THE DISCARD GOES IS THE OTHER HALF OF THE RULING. "If someone else wants to pick it up,
|
||||||
* once and ends in the Salvage Yard while a Timetabled card joins the timetable for the rest of
|
* they are more than able to" — a discard goes face-up on a Department pile, which is exactly
|
||||||
* the game.
|
* where a rival can draw it from, so the second half needed no machinery at all.
|
||||||
*
|
*
|
||||||
* `legal.ts` enumerates candidates and filters them through here, so the discard option simply
|
* The corner Gitea#6 created still exists when the setting is off, and is still deliberate: a
|
||||||
* stops being offered for these cards; the bot needs no separate rule and already reaches for
|
* player holding four undiscardable trains has one way forward, which is to PLAY one. `draw.end`
|
||||||
* `card.play` before it reaches for a discard.
|
* refuses while the hand is over the limit, and playing a train card is unconditionally legal
|
||||||
|
* (`card.play`'s `timetabledTrain` case refuses only a board placement), so it can never lock.
|
||||||
|
*
|
||||||
|
* `legal.ts` enumerates candidates and filters them through here, so an undiscardable card
|
||||||
|
* simply stops being offered; the bot needs no separate rule.
|
||||||
*/
|
*/
|
||||||
case 'card.discard': {
|
case 'card.discard': {
|
||||||
if (!inPhase(s, 'localOps')) return 'WRONG_PHASE';
|
if (!inPhase(s, 'localOps')) return 'WRONG_PHASE';
|
||||||
const hand = s.decks.hands.get(player) ?? [];
|
const hand = s.decks.hands.get(player) ?? [];
|
||||||
if (!hand.includes(i.cardId)) return 'CARD_NOT_IN_HAND';
|
if (!hand.includes(i.cardId)) return 'CARD_NOT_IN_HAND';
|
||||||
if (i.toSlot < 0 || i.toSlot > 2) return 'SLOT_EMPTY';
|
if (i.toSlot < 0 || i.toSlot > 2) return 'SLOT_EMPTY';
|
||||||
if (isTrainCard(s, i.cardId)) return 'TRAINS_ARE_NEVER_DISCARDED';
|
if (keepReason(s, i.cardId) !== null) return 'TRAINS_ARE_NEVER_DISCARDED';
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+32
-1
@@ -1058,13 +1058,36 @@ export type RevenueRules = {
|
|||||||
*/
|
*/
|
||||||
export type ExtraStartRule = 'divisionPointsOnly' | 'ownOffice' | 'anyOffice';
|
export type ExtraStartRule = 'divisionPointsOnly' | 'ownOffice' | 'anyOffice';
|
||||||
|
|
||||||
export type HouseRules = { startingHand: StartingHand; revenue: RevenueRules; extraStart: ExtraStartRule };
|
export type HouseRules = {
|
||||||
|
startingHand: StartingHand;
|
||||||
|
revenue: RevenueRules;
|
||||||
|
extraStart: ExtraStartRule;
|
||||||
|
/**
|
||||||
|
* §6.2 — MAY A TIMETABLED TRAIN BE THROWN AWAY? (Gitea#9, superseding Gitea#6.)
|
||||||
|
*
|
||||||
|
* Jesse: "Timetabled trains are at the choice of the player — they can either play or discard. If
|
||||||
|
* someone else wants to pick it up, they are more than able to. The reason: I don't want, if you
|
||||||
|
* decide to play a game longer than five days, to decide that maybe there are too many trains, the
|
||||||
|
* stations are jammed, and the railroad doesn't need any more. You can toss it. Someone else might
|
||||||
|
* disagree and pick it up."
|
||||||
|
*
|
||||||
|
* A setting rather than a flat rule because Jesse asked for it as one — the reasoning above is
|
||||||
|
* about LONG games, and a five-Day game may well want the pressure Gitea#6 created. Discarding
|
||||||
|
* puts the card face-up on a Department pile, so "someone else might pick it up" needs no
|
||||||
|
* machinery of its own: that is where every discard already goes.
|
||||||
|
*
|
||||||
|
* AN EXTRA IS NEVER DISCARDED WHATEVER THIS SAYS. Gitea#9 is about the timetable filling up, and
|
||||||
|
* an Extra never joins it — it runs once and ends in the Salvage Yard, so it cannot jam anything.
|
||||||
|
*/
|
||||||
|
discardTimetabled: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
/** What a caller may name — any subset, down to none — resolved by `houseRules()`. */
|
/** What a caller may name — any subset, down to none — resolved by `houseRules()`. */
|
||||||
export type HouseRuleOverrides = {
|
export type HouseRuleOverrides = {
|
||||||
startingHand?: StartingHand;
|
startingHand?: StartingHand;
|
||||||
revenue?: Partial<RevenueRules>;
|
revenue?: Partial<RevenueRules>;
|
||||||
extraStart?: ExtraStartRule;
|
extraStart?: ExtraStartRule;
|
||||||
|
discardTimetabled?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** The dialog's range. Zero is a real setting: it switches an economy off so the others can be read. */
|
/** The dialog's range. Zero is a real setting: it switches an economy off so the others can be read. */
|
||||||
@@ -1078,6 +1101,10 @@ export const DEFAULT_HOUSE_RULES: HouseRules = {
|
|||||||
// it plays the way it always has. Jesse's call, so the 0.4.9 playtest line does not change under
|
// it plays the way it always has. Jesse's call, so the 0.4.9 playtest line does not change under
|
||||||
// its testers in the middle of a bugfix release.
|
// its testers in the middle of a bugfix release.
|
||||||
extraStart: 'anyOffice',
|
extraStart: 'anyOffice',
|
||||||
|
// Gitea#9's ruling is the default, so a game dealt without naming it plays the rule Jesse most
|
||||||
|
// recently gave rather than the one it replaced. This keeps main and the 0.4.9 playtest line —
|
||||||
|
// which has no setting and simply allows it — playing the same game.
|
||||||
|
discardTimetabled: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1094,6 +1121,9 @@ export const LEGACY_HOUSE_RULES: HouseRules = {
|
|||||||
revenue: { passengerPerCoach: 1, freightPerLoad: 1, trainPerTransit: 1 },
|
revenue: { passengerPerCoach: 1, freightPerLoad: 1, trainPerTransit: 1 },
|
||||||
// An Extra could always be started at a Control Point in these games, in any district.
|
// An Extra could always be started at a Control Point in these games, in any district.
|
||||||
extraStart: 'anyOffice',
|
extraStart: 'anyOffice',
|
||||||
|
// These games predate Gitea#6 as well as Gitea#9: a train card could simply be discarded. `true`
|
||||||
|
// is what they were played under, and a replay that discards a Timetabled train needs it.
|
||||||
|
discardTimetabled: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
/** A whole, valid rule set from a config that may carry none, some, or out-of-range values. */
|
/** A whole, valid rule set from a config that may carry none, some, or out-of-range values. */
|
||||||
@@ -1113,6 +1143,7 @@ export function houseRules(config: { houseRules?: HouseRuleOverrides }): HouseRu
|
|||||||
trainPerTransit: clamp(rev.trainPerTransit, d.revenue.trainPerTransit),
|
trainPerTransit: clamp(rev.trainPerTransit, d.revenue.trainPerTransit),
|
||||||
},
|
},
|
||||||
extraStart: given.extraStart ?? d.extraStart,
|
extraStart: given.extraStart ?? d.extraStart,
|
||||||
|
discardTimetabled: given.discardTimetabled ?? d.discardTimetabled,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+113
-3
@@ -18,7 +18,7 @@
|
|||||||
import { MAX_CONSIST } from '../engine/content.ts';
|
import { MAX_CONSIST } from '../engine/content.ts';
|
||||||
import { adTrackCount, coordKey, seatOf, turnOf } from '../engine/state.ts';
|
import { adTrackCount, coordKey, seatOf, turnOf } from '../engine/state.ts';
|
||||||
import type { GameState, GridCoord, PlayerIndex, RollingStock, SeatIndex, TrayId } from '../engine/state.ts';
|
import type { GameState, GridCoord, PlayerIndex, RollingStock, SeatIndex, TrayId } from '../engine/state.ts';
|
||||||
import { areaOf, canAdvanceLoad, canStartLoad, facilityCarType, facilityCarTypes, laborersLeft, movesFor, portersLeft } from '../engine/apply.ts';
|
import { areaOf, canAdvanceLoad, canBoard, canDetrain, canStartLoad, facilityCarType, facilityCarTypes, laborersLeft, movesFor, passengerRefusal, portersLeft } from '../engine/apply.ts';
|
||||||
import type { GameEvent } from '../engine/events.ts';
|
import type { GameEvent } from '../engine/events.ts';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -524,14 +524,124 @@ export type Impediment = { where: string; why: string; severity: 'stuck' | 'wait
|
|||||||
* This is the panel that should answer the standing questions: whether facilities jam, whether
|
* This is the panel that should answer the standing questions: whether facilities jam, whether
|
||||||
* trains are held for want of a crew, whether the Office is about to cause a collision.
|
* trains are held for want of a crew, whether the Office is about to cause a collision.
|
||||||
*/
|
*/
|
||||||
|
/** `coordKey`'s inverse — the grid is keyed by string and the engine predicates take coordinates. */
|
||||||
|
function uncoordKey(key: string): GridCoord {
|
||||||
|
const [row, col] = key.split(',').map(Number);
|
||||||
|
return { row: row ?? 0, col: col ?? 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One of `passengerRefusal`'s codes, in words a player can act on.
|
||||||
|
*
|
||||||
|
* `NO_EMPTY_COACH_IN_YARD` gets the longest answer because it is the one that looks like a broken
|
||||||
|
* game: the Division Yard is visibly full of cars, and the single type that has run out is the one
|
||||||
|
* §9.2 needs. Where the missing coaches ARE, and the condition that brings them back, is the whole
|
||||||
|
* of what the player needs to know — §2.2 returns the Classification Yard only when the Division
|
||||||
|
* Yard is bare, so a yard with fifty freight cars in it will not refill for a long time.
|
||||||
|
*/
|
||||||
|
function passengerReason(
|
||||||
|
s: GameState,
|
||||||
|
player: PlayerIndex,
|
||||||
|
at: GridCoord,
|
||||||
|
dir: 'board' | 'detrain',
|
||||||
|
): string {
|
||||||
|
const code = passengerRefusal(s, player, at, dir);
|
||||||
|
switch (code) {
|
||||||
|
case 'NO_TRAIN_AT_OFFICE':
|
||||||
|
return dir === 'board'
|
||||||
|
? 'passengers waiting, no train at the platform to take them'
|
||||||
|
: 'no train at the platform';
|
||||||
|
case 'NOT_A_TERMINAL':
|
||||||
|
return 'the only train here stops at Terminals only — Porters may not work it at this Office';
|
||||||
|
case 'NO_PASSENGER_WORK':
|
||||||
|
return 'the only train here is one its card bars Porters from working';
|
||||||
|
case 'NO_EMPTY_COACH':
|
||||||
|
return 'passengers waiting, but every coach on the train is already full';
|
||||||
|
case 'INBOUND_BOX_FULL':
|
||||||
|
return 'arrivals aboard, but the red Unloading slots are all occupied';
|
||||||
|
case 'LOADED_IN_THIS_DISTRICT':
|
||||||
|
return 'the loaded coaches all boarded here — passengers must be carried to another Office ' +
|
||||||
|
'Area before they can alight';
|
||||||
|
case 'NO_EMPTY_COACH_IN_YARD': {
|
||||||
|
const stuck = s.yards.classificationYard.filter((c) => c.type === 'coach').length;
|
||||||
|
const total = s.yards.divisionYard.length;
|
||||||
|
return (
|
||||||
|
'arrivals aboard, but §9.2 needs a white empty coach from the Division Yard to swap in and ' +
|
||||||
|
`there is none left${stuck > 0 ? ` — ${stuck} ${stuck === 1 ? 'coach is' : 'coaches are'} in the Classification Yard` : ''}. ` +
|
||||||
|
`Classification returns only when the Division Yard is bare, and it still holds ${total} cars.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return `Porters cannot work here (${code})`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function impediments(s: GameState, player: PlayerIndex = 0): Impediment[] {
|
export function impediments(s: GameState, player: PlayerIndex = 0): Impediment[] {
|
||||||
const out: Impediment[] = [];
|
const out: Impediment[] = [];
|
||||||
const area = areaOf(s, player);
|
const area = areaOf(s, player);
|
||||||
|
|
||||||
for (const [key, card] of area.grid) {
|
for (const [key, card] of area.grid) {
|
||||||
const f = card.facility;
|
const f = card.facility;
|
||||||
if (!f || f.kind !== 'freight') continue;
|
if (!f) continue;
|
||||||
const name = card.geometry.kind === 'facility' ? card.geometry.facility : 'facility';
|
/**
|
||||||
|
* A Freight Facility names itself off its own card; a Passenger Facility does NOT — it rides on
|
||||||
|
* the `office` card, so `geometry.kind` is `'office'` and it fell through to the literal
|
||||||
|
* "facility". Every passenger impediment therefore read `facility 0,0`, next to a freight row
|
||||||
|
* saying `mineTipple 1,-3`. The Office Area's tier is the name it should carry, and there is
|
||||||
|
* exactly one Office per Area, so `area.tier` is that card's own.
|
||||||
|
*/
|
||||||
|
const name =
|
||||||
|
card.geometry.kind === 'facility'
|
||||||
|
? card.geometry.facility
|
||||||
|
: card.geometry.kind === 'office'
|
||||||
|
? area.tier
|
||||||
|
: 'facility';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WHY THE PORTERS ARE STANDING THERE (Gitea#2).
|
||||||
|
*
|
||||||
|
* "Note that the sparrow (with two loaded coaches) pulled into the station. There are two
|
||||||
|
* passengers on the platform. Four porters. My thought was to unload two and load two. I never
|
||||||
|
* get the chance to load the last two."
|
||||||
|
*
|
||||||
|
* The engine was right — §9.2 needs a white coach out of the Division Yard to de-train into,
|
||||||
|
* §2.2 returns the Classification Yard only when the Division Yard is BARE, and the Division
|
||||||
|
* Yard was one empty coach short with eight more sitting in Classification unable to come back.
|
||||||
|
* Jesse's ruling is that the shortage stays: "it is possible to run out — that's part of the
|
||||||
|
* strategy." What was missing was any way to SEE it. A Porter action that cannot be taken is
|
||||||
|
* simply absent from the menu, and this panel — the one that answers "why is nothing moving?" —
|
||||||
|
* covered freight facilities only, so the platform had nothing to say for itself at all.
|
||||||
|
*
|
||||||
|
* The reason comes from `passengerRefusal`, the engine's own, so what is on screen is the rule
|
||||||
|
* that actually refused rather than a second guess at it.
|
||||||
|
*/
|
||||||
|
if (f.kind === 'passenger') {
|
||||||
|
if (portersLeft(f) > 0) {
|
||||||
|
const coord = uncoordKey(key);
|
||||||
|
// Passengers standing on the platform with nothing carrying them away.
|
||||||
|
if (f.outboundBox.some((c) => c.type === 'coach' && c.loaded) && !canBoard(s, player, coord)) {
|
||||||
|
out.push({
|
||||||
|
where: `${name} ${key}`,
|
||||||
|
why: passengerReason(s, player, coord, 'board'),
|
||||||
|
severity: 'waiting',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// A coach full of arrivals that cannot be emptied.
|
||||||
|
const arriving = area.adOccupancy.some((id) =>
|
||||||
|
s.trays.get(id)?.consist.some((c) => c.type === 'coach' && c.loaded && c.origin !== seatOf(s, player)),
|
||||||
|
);
|
||||||
|
if (arriving && !canDetrain(s, player, coord)) {
|
||||||
|
out.push({
|
||||||
|
where: `${name} ${key}`,
|
||||||
|
why: passengerReason(s, player, coord, 'detrain'),
|
||||||
|
severity: 'stuck',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (f.kind !== 'freight') continue;
|
||||||
const want = facilityCarType(f);
|
const want = facilityCarType(f);
|
||||||
|
|
||||||
// A load that cannot move, with Laborers standing by, is the worst state a facility reaches:
|
// A load that cannot move, with Laborers standing by, is the worst state a facility reaches:
|
||||||
|
|||||||
+20
-7
@@ -18,7 +18,7 @@ import {
|
|||||||
laborersLeft,
|
laborersLeft,
|
||||||
movesFor,
|
movesFor,
|
||||||
ownCutFor,
|
ownCutFor,
|
||||||
isTrainCard,
|
keepReason,
|
||||||
portersLeft,
|
portersLeft,
|
||||||
resolveExtraStart,
|
resolveExtraStart,
|
||||||
selectDestination,
|
selectDestination,
|
||||||
@@ -492,12 +492,22 @@ export type Frame = {
|
|||||||
/**
|
/**
|
||||||
* Whether each hand card may be DISCARDED, in the same order.
|
* Whether each hand card may be DISCARDED, in the same order.
|
||||||
*
|
*
|
||||||
* §6.2 as ruled by Jesse (Gitea#6): a train card never can be. The player has to be told which
|
* The player has to be told which cards those are, not merely find that a button is missing —
|
||||||
* cards those are, not merely find that a button is missing — that silence is the whole of the
|
* that silence is the whole of the Gitea#2 complaint, where a blocked platform left the board with
|
||||||
* Gitea#2 complaint, where a blocked platform left the board with nothing to click and no reason.
|
* nothing to click and no reason. Named for the rule rather than for trains, since it answers the
|
||||||
* Named for the rule rather than for trains, since it answers the question the panel is asking.
|
* question the panel is asking.
|
||||||
*/
|
*/
|
||||||
handDiscardable: boolean[];
|
handDiscardable: boolean[];
|
||||||
|
/**
|
||||||
|
* WHY a card may not be discarded, in the same order; `null` where it may.
|
||||||
|
*
|
||||||
|
* Carried rather than written on the page because §6.2 now fails for two different reasons
|
||||||
|
* (Gitea#9): an Extra is never discardable, and a Timetabled train is not discardable only when
|
||||||
|
* the `discardTimetabled` house rule is off. A panel that hard-codes one sentence tells half the
|
||||||
|
* players the wrong thing, and a panel that reconstructs the rule is a second implementation of
|
||||||
|
* it. `keepReason` is the engine's own, so the card says the rule that actually refused.
|
||||||
|
*/
|
||||||
|
handKeepWhy: (string | null)[];
|
||||||
deck: number;
|
deck: number;
|
||||||
/** The face-up card on top of each Department pile — the only one that may be drawn. */
|
/** The face-up card on top of each Department pile — the only one that may be drawn. */
|
||||||
departments: string[];
|
departments: string[];
|
||||||
@@ -1294,7 +1304,8 @@ export function snapshot(
|
|||||||
*/
|
*/
|
||||||
hand: [...(s.decks.hands.get(viewer) ?? [])].reverse().map((id) => cardName(s, id)),
|
hand: [...(s.decks.hands.get(viewer) ?? [])].reverse().map((id) => cardName(s, id)),
|
||||||
handWhat: [...(s.decks.hands.get(viewer) ?? [])].reverse().map((id) => cardDescription(s, id)),
|
handWhat: [...(s.decks.hands.get(viewer) ?? [])].reverse().map((id) => cardDescription(s, id)),
|
||||||
handDiscardable: [...(s.decks.hands.get(viewer) ?? [])].reverse().map((id) => !isTrainCard(s, id)),
|
handDiscardable: [...(s.decks.hands.get(viewer) ?? [])].reverse().map((id) => keepReason(s, id) === null),
|
||||||
|
handKeepWhy: [...(s.decks.hands.get(viewer) ?? [])].reverse().map((id) => keepReason(s, id)),
|
||||||
deck: s.decks.homeOffice.length,
|
deck: s.decks.homeOffice.length,
|
||||||
departments: s.decks.departments.map((pile) => {
|
departments: s.decks.departments.map((pile) => {
|
||||||
const top = pile[pile.length - 1];
|
const top = pile[pile.length - 1];
|
||||||
@@ -1575,7 +1586,9 @@ export function trainRules(t: {
|
|||||||
}
|
}
|
||||||
if (p.rules.noPassengerWork) parts.push('NO PASSENGER WORK — Porters may not board or detrain it');
|
if (p.rules.noPassengerWork) parts.push('NO PASSENGER WORK — Porters may not board or detrain it');
|
||||||
if (p.rules.dropOnly) parts.push('MAY DROP BUT NOT PICK UP — it cannot couple anything');
|
if (p.rules.dropOnly) parts.push('MAY DROP BUT NOT PICK UP — it cannot couple anything');
|
||||||
if (p.rules.pickUpEmptiesOnly) parts.push('EMPTIES ONLY — it may not couple a loaded car');
|
if (p.rules.pickUpEmptiesOnly) {
|
||||||
|
parts.push('EMPTIES ONLY — it may not couple a loaded car. A caboose is not a load.');
|
||||||
|
}
|
||||||
if (p.rules.stopThenExpedite) {
|
if (p.rules.stopThenExpedite) {
|
||||||
parts.push('STOPS ONCE FOR SPEECHES, then runs expedited from its next Office onward');
|
parts.push('STOPS ONCE FOR SPEECHES, then runs expedited from its next Office onward');
|
||||||
}
|
}
|
||||||
|
|||||||
+61
-11
@@ -10,7 +10,7 @@ import { TURNCHART_CSS, turnChartHtml } from '../sim/turnchart.ts';
|
|||||||
import type { Frame } from '../sim/view.ts';
|
import type { Frame } from '../sim/view.ts';
|
||||||
import { seatLabel } from '../sim/view.ts';
|
import { seatLabel } from '../sim/view.ts';
|
||||||
import type { Menu, Save } from './game.ts';
|
import type { Menu, Save } from './game.ts';
|
||||||
import { PANEL_CSS, blockedHtml, facilitiesHtml, pilesHtml, timetableHtml, yardHtml } from './panels.ts';
|
import { PANEL_CSS, blockedHtml, dayEndHtml, facilitiesHtml, pilesHtml, timetableHtml, yardHtml } from './panels.ts';
|
||||||
import { TOOLTIP_CSS, installTooltips } from './tooltip.ts';
|
import { TOOLTIP_CSS, installTooltips } from './tooltip.ts';
|
||||||
import { playCue } from './sound.ts';
|
import { playCue } from './sound.ts';
|
||||||
import {
|
import {
|
||||||
@@ -158,6 +158,13 @@ let zoom = settings.zoom;
|
|||||||
* ended or what happened in between. Reported exactly that way.
|
* ended or what happened in between. Reported exactly that way.
|
||||||
*/
|
*/
|
||||||
let lastPhase: string | null = null;
|
let lastPhase: string | null = null;
|
||||||
|
/**
|
||||||
|
* The Day the page last drew, so a Day rolling over can be shown as a dialog (Gitea#10).
|
||||||
|
*
|
||||||
|
* Null until the first frame: arriving in a game already on Day 3 is not Day 2 ending, and a page
|
||||||
|
* reloaded mid-game would otherwise announce a rollover that happened before it was watching.
|
||||||
|
*/
|
||||||
|
let lastDay: number | null = null;
|
||||||
/**
|
/**
|
||||||
* WHICH CREW THE BOARD IS DRAWING, when the district holds more than one.
|
* WHICH CREW THE BOARD IS DRAWING, when the district holds more than one.
|
||||||
*
|
*
|
||||||
@@ -302,6 +309,10 @@ function gameOptionsFromUrl(params: URLSearchParams): NewGameOptions {
|
|||||||
if (STARTING_HAND_LABELS.some((o) => o.value === hand)) rules.startingHand = hand as StartingHand;
|
if (STARTING_HAND_LABELS.some((o) => o.value === hand)) rules.startingHand = hand as StartingHand;
|
||||||
const extra = params.get('extra');
|
const extra = params.get('extra');
|
||||||
if (EXTRA_START_LABELS.some((o) => o.value === extra)) rules.extraStart = extra as ExtraStartRule;
|
if (EXTRA_START_LABELS.some((o) => o.value === extra)) rules.extraStart = extra as ExtraStartRule;
|
||||||
|
// §6.2 (Gitea#9). This one defaults ON, so the URL only ever has to carry the OFF case — `?toss=0`.
|
||||||
|
// Read the same way the optional rules are: present and not "0" means on.
|
||||||
|
const toss = params.get('toss');
|
||||||
|
if (toss !== null) rules.discardTimetabled = toss !== '0';
|
||||||
|
|
||||||
const revenue: Partial<RevenueRules> = {};
|
const revenue: Partial<RevenueRules> = {};
|
||||||
for (const [param, key] of Object.entries(RULE_PARAMS)) {
|
for (const [param, key] of Object.entries(RULE_PARAMS)) {
|
||||||
@@ -358,6 +369,7 @@ function solitaireDefaults(options: NewGameOptions): NewGameOptions {
|
|||||||
houseRules: {
|
houseRules: {
|
||||||
startingHand: options.houseRules?.startingHand ?? p.startingHand,
|
startingHand: options.houseRules?.startingHand ?? p.startingHand,
|
||||||
extraStart: options.houseRules?.extraStart ?? p.extraStart,
|
extraStart: options.houseRules?.extraStart ?? p.extraStart,
|
||||||
|
discardTimetabled: options.houseRules?.discardTimetabled ?? p.discardTimetabled,
|
||||||
revenue: {
|
revenue: {
|
||||||
passengerPerCoach: revenue.passengerPerCoach ?? p.passengerPerCoach,
|
passengerPerCoach: revenue.passengerPerCoach ?? p.passengerPerCoach,
|
||||||
freightPerLoad: revenue.freightPerLoad ?? p.freightPerLoad,
|
freightPerLoad: revenue.freightPerLoad ?? p.freightPerLoad,
|
||||||
@@ -374,6 +386,9 @@ function rulesToUrl(rules: HouseRules, options: NewGameOptions, seed: string): s
|
|||||||
// The dialog answers reach `start()` through the URL and nowhere else, so a setting missing from
|
// The dialog answers reach `start()` through the URL and nowhere else, so a setting missing from
|
||||||
// here is a setting the dialog silently discards.
|
// here is a setting the dialog silently discards.
|
||||||
params.set('extra', rules.extraStart);
|
params.set('extra', rules.extraStart);
|
||||||
|
// Written only when OFF, for the reason the optional rules are written only when on: this one
|
||||||
|
// defaults to on, so `toss=1` on every link would say nothing and cost a parameter.
|
||||||
|
if (!rules.discardTimetabled) params.set('toss', '0');
|
||||||
for (const [param, key] of Object.entries(RULE_PARAMS)) params.set(param, String(rules.revenue[key]));
|
for (const [param, key] of Object.entries(RULE_PARAMS)) params.set(param, String(rules.revenue[key]));
|
||||||
for (const [param, key] of Object.entries(VICTORY_PARAMS)) {
|
for (const [param, key] of Object.entries(VICTORY_PARAMS)) {
|
||||||
const value = options[key];
|
const value = options[key];
|
||||||
@@ -561,6 +576,34 @@ function flashAnnounce(text: string): void {
|
|||||||
}, 4200);
|
}, 4200);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* THE DAY ROLLING OVER, as a dialog that has to be dismissed (Gitea#10).
|
||||||
|
*
|
||||||
|
* "As the game rolls off the end of the day, you get a dialog saying such. Hard to keep track of
|
||||||
|
* time." Nothing on screen was wrong — the clock, the turn chart and the timetable all said which
|
||||||
|
* Day it was — but a Day turns over inside the phases that run themselves, so it happens while the
|
||||||
|
* player is looking at the board waiting for their next turn. The two transient signals the page
|
||||||
|
* already had are both gone in under five seconds.
|
||||||
|
*
|
||||||
|
* Not shown when:
|
||||||
|
* - this is the first frame (`lastDay === null`) — arriving on Day 3 is not Day 2 ending;
|
||||||
|
* - the Day went DOWN, which is Undo stepping back across the rollover, not a Day passing;
|
||||||
|
* - the game finished on that rollover, when the outcome panel is the thing to read instead.
|
||||||
|
*/
|
||||||
|
function noteDayEnd(f: Frame): void {
|
||||||
|
const previous = lastDay;
|
||||||
|
lastDay = f.day;
|
||||||
|
if (previous === null || f.day <= previous) return;
|
||||||
|
if (f.status !== 'active') return;
|
||||||
|
const dlg = document.getElementById('dayenddlg') as HTMLDialogElement | null;
|
||||||
|
const body = document.getElementById('dayendbody');
|
||||||
|
if (!dlg || !body) return;
|
||||||
|
body.innerHTML = dayEndHtml(f);
|
||||||
|
// A second rollover cannot happen while this one is open, but a redraw can — `showModal` throws
|
||||||
|
// on an already-open dialog rather than doing nothing.
|
||||||
|
if (!dlg.open) dlg.showModal();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* THE FIRST FRAME OF A MULTIPLAYER GAME — the one moment nobody had ever seen drawn.
|
* THE FIRST FRAME OF A MULTIPLAYER GAME — the one moment nobody had ever seen drawn.
|
||||||
*
|
*
|
||||||
@@ -1186,6 +1229,8 @@ function render(): void {
|
|||||||
}
|
}
|
||||||
lastPhase = f.phaseKey;
|
lastPhase = f.phaseKey;
|
||||||
|
|
||||||
|
noteDayEnd(f);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A train completing its run pays every player and nobody took a turn to cause it, so it is said
|
* A train completing its run pays every player and nobody took a turn to cause it, so it is said
|
||||||
* out loud rather than left in the log. Drained, so it shows once and does not re-fire on a redraw.
|
* out loud rather than left in the log. Drained, so it shows once and does not re-fire on a redraw.
|
||||||
@@ -1236,6 +1281,9 @@ function renderUndo(): void {
|
|||||||
// A phase change is announced by comparing against the last frame drawn. Stepping BACK into a
|
// A phase change is announced by comparing against the last frame drawn. Stepping BACK into a
|
||||||
// different phase is not that event, so the banner is suppressed rather than fired backwards.
|
// different phase is not that event, so the banner is suppressed rather than fired backwards.
|
||||||
lastPhase = null;
|
lastPhase = null;
|
||||||
|
// Same for the Day: `noteDayEnd` already ignores a Day going down, but undoing back across a
|
||||||
|
// rollover and then replaying forward through it would announce the same Day ending twice.
|
||||||
|
lastDay = null;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1577,20 +1625,21 @@ function renderActions(
|
|||||||
/**
|
/**
|
||||||
* WHEN NOTHING IN HAND MAY BE DISCARDED, SAY SO AND SAY WHAT TO DO INSTEAD.
|
* WHEN NOTHING IN HAND MAY BE DISCARDED, SAY SO AND SAY WHAT TO DO INSTEAD.
|
||||||
*
|
*
|
||||||
* §6.2 as ruled by Jesse (Gitea#6): a train card is never discarded, so a player holding four
|
* §6.2 (Gitea#9) leaves two kinds of undiscardable card — an Extra always, and a Timetabled
|
||||||
* trains has exactly one way forward — play one onto the timetable. The rule creates that corner
|
* train in a game whose `discardTimetabled` rule is off. Either way a player holding nothing but
|
||||||
* deliberately and needs no machinery, but it must not be a corner the player has to infer from
|
* those has exactly one way forward: play one. The rule creates that corner deliberately and
|
||||||
* a discard button that has quietly stopped appearing.
|
* needs no machinery, but it must not be a corner the player has to infer from a discard button
|
||||||
|
* that has quietly stopped appearing. The reason is the card's own (`handKeepWhy`), so this says
|
||||||
|
* what actually refused rather than assuming which of the two rules is in force.
|
||||||
*/
|
*/
|
||||||
const stuck = f.handDiscardable.length > 0 && f.handDiscardable.every((d) => !d);
|
const stuck = f.handDiscardable.length > 0 && f.handDiscardable.every((d) => !d);
|
||||||
|
const why = f.handKeepWhy.find((w) => w !== null) ?? '';
|
||||||
const tip = stuck
|
const tip = stuck
|
||||||
? 'You may not end a turn holding more than three cards, and a TRAIN CARD IS NEVER ' +
|
? 'You may not end a turn holding more than three cards, and every card you hold is one that ' +
|
||||||
'DISCARDED. Every card you hold is a train, so the only way on is to play one onto the ' +
|
`cannot be thrown away. ${why} The only way on is to play one.`
|
||||||
'timetable. A train may be held for as many Stages and Days as you like; it just cannot be ' +
|
|
||||||
'thrown away.'
|
|
||||||
: 'You may not end a turn holding more than three cards (four with a Red Flag). Play ' +
|
: 'You may not end a turn holding more than three cards (four with a Red Flag). Play ' +
|
||||||
'one onto the board, or discard one face-up to a Department slot. A train card is never ' +
|
'one onto the board, or discard one face-up to a Department slot — where a rival may pick ' +
|
||||||
'discardable and can only be played.';
|
'it up.';
|
||||||
html +=
|
html +=
|
||||||
`<div class="grp"><button class="act blocked" disabled data-tip="${tip.replace(/"/g, '"')}">` +
|
`<div class="grp"><button class="act blocked" disabled data-tip="${tip.replace(/"/g, '"')}">` +
|
||||||
(stuck
|
(stuck
|
||||||
@@ -1888,6 +1937,7 @@ if (newBtn && dlg) {
|
|||||||
houseRules: {
|
houseRules: {
|
||||||
startingHand: settings.startingHand,
|
startingHand: settings.startingHand,
|
||||||
extraStart: settings.extraStart,
|
extraStart: settings.extraStart,
|
||||||
|
discardTimetabled: settings.discardTimetabled,
|
||||||
revenue: {
|
revenue: {
|
||||||
passengerPerCoach: settings.passengerPerCoach,
|
passengerPerCoach: settings.passengerPerCoach,
|
||||||
freightPerLoad: settings.freightPerLoad,
|
freightPerLoad: settings.freightPerLoad,
|
||||||
|
|||||||
+78
-6
@@ -31,16 +31,16 @@ export function cardRow(name: string, why: string, playable: boolean | null): st
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function handHtml(f: Frame, canPlay: (boolean | null)[] = []): string {
|
export function handHtml(f: Frame, canPlay: (boolean | null)[] = []): string {
|
||||||
// §6.2 (Gitea#6) — say so on the card itself. A player who cannot discard a train needs to read
|
// §6.2 — say so on the card itself. A player who cannot discard a train needs to read that on the
|
||||||
// that on the train, not deduce it from a button that is not there.
|
// train, not deduce it from a button that is not there. The sentence comes off the Frame
|
||||||
const held = 'You may hold this for as many Stages and Days as you like — but a train card is ' +
|
// (`handKeepWhy`) rather than being written here, because since Gitea#9 there are two of them and
|
||||||
'never discarded. The only way it leaves your hand is onto the timetable.';
|
// which one applies depends on the card AND the game's rules.
|
||||||
return f.hand.length
|
return f.hand.length
|
||||||
? f.hand
|
? f.hand
|
||||||
.map((h, i) => {
|
.map((h, i) => {
|
||||||
const what = f.handWhat[i] ?? '';
|
const what = f.handWhat[i] ?? '';
|
||||||
const keep = f.handDiscardable[i] === false;
|
const held = f.handKeepWhy[i];
|
||||||
return cardRow(h, keep ? [what, held].filter(Boolean).join(' · ') : what, canPlay[i] ?? null);
|
return cardRow(h, held ? [what, held].filter(Boolean).join(' · ') : what, canPlay[i] ?? null);
|
||||||
})
|
})
|
||||||
.join('')
|
.join('')
|
||||||
: '<span class="dim">empty</span>';
|
: '<span class="dim">empty</span>';
|
||||||
@@ -153,6 +153,70 @@ export function timetableHtml(f: Frame, justSet: number | null): string {
|
|||||||
return `<div class="tt">${slots}</div>`;
|
return `<div class="tt">${slots}</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* THE DAY THAT JUST ENDED — the body of the dialog `main.ts` puts up at every Day rollover.
|
||||||
|
*
|
||||||
|
* Reported as Gitea#10: "as the game rolls off the end of the day, you get a dialog saying such.
|
||||||
|
* Hard to keep track of time." The clock was on screen the whole time, but a Day turns over inside
|
||||||
|
* the automatic phases — between one click and the next — and neither the phase banner (2.6s) nor
|
||||||
|
* the announcement flash (4.2s) survives long enough to be noticed by someone reading the board.
|
||||||
|
* A modal is the point: it stops, and it waits to be dismissed.
|
||||||
|
*
|
||||||
|
* It is written from the FRAME AFTER the rollover, so `f.day` is the Day about to start and the one
|
||||||
|
* that ended is the Day before it. Standings are in Revenue order rather than seat order: the
|
||||||
|
* question at the end of a Day is who is ahead.
|
||||||
|
*/
|
||||||
|
export function dayEndHtml(f: Frame): string {
|
||||||
|
const ended = f.day - 1;
|
||||||
|
const left = f.days - ended;
|
||||||
|
const standings = [...f.players]
|
||||||
|
.sort((a, b) => b.revenue - a.revenue || a.seat - b.seat)
|
||||||
|
.map(
|
||||||
|
(p) =>
|
||||||
|
`<tr${p.index === f.viewer ? ' class="you"' : ''}><td>${esc(p.name)}` +
|
||||||
|
`${p.index === f.viewer ? ' <span class="dim">(you)</span>' : ''}</td>` +
|
||||||
|
`<td class="num">${p.revenue}</td></tr>`,
|
||||||
|
)
|
||||||
|
.join('');
|
||||||
|
|
||||||
|
// The target is a COMBINED floor in every mode that sets one, so it is reported against the whole
|
||||||
|
// table's Revenue rather than the viewer's — showing one player's score against a four-player
|
||||||
|
// target reads as a hopeless position when the table may be comfortably ahead.
|
||||||
|
const combined = f.players.reduce((n, p) => n + p.revenue, 0);
|
||||||
|
const target =
|
||||||
|
f.minCombinedRevenue > 0
|
||||||
|
? `<p>Combined Revenue <b>${combined}</b> against a target of <b>${f.minCombinedRevenue}</b>.</p>`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Only when the game is actually scored on collisions.
|
||||||
|
*
|
||||||
|
* Two conditions, both of them `advance.ts`'s own: `0` on a dial turns that check off, and the
|
||||||
|
* checks run in COMPETITIVE AND CO-OP ONLY (§3.4). A solitaire game carries the default dials on
|
||||||
|
* its config and enforces neither, so reporting a collision budget there would put a rule on
|
||||||
|
* screen that this game does not have.
|
||||||
|
*/
|
||||||
|
const scoredOnCollisions =
|
||||||
|
(f.mode === 'competitive' || f.mode === 'coop') &&
|
||||||
|
(f.maxCollisionsTotal > 0 || f.maxCollisionsPerDay > 0);
|
||||||
|
const collisions = scoredOnCollisions
|
||||||
|
? `<p>Collisions: <b>${f.collisionsToday}</b> today, <b>${f.collisionsTotal}</b> in all.</p>`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const ahead =
|
||||||
|
left <= 0
|
||||||
|
? '<p>That was the last Day on the timetable.</p>'
|
||||||
|
: `<p><b>Day ${f.day} of ${f.days}</b> begins now — ${left} ${left === 1 ? 'Day' : 'Days'} left to run.</p>`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
`<h3 class="dayend-h">Day ${ended} has ended</h3>` +
|
||||||
|
ahead +
|
||||||
|
`<table class="dayend-t"><tbody>${standings}</tbody></table>` +
|
||||||
|
target +
|
||||||
|
collisions
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function blockedHtml(f: Frame): string {
|
export function blockedHtml(f: Frame): string {
|
||||||
return f.blocked.length === 0
|
return f.blocked.length === 0
|
||||||
? '<li class="dim">nothing blocked</li>'
|
? '<li class="dim">nothing blocked</li>'
|
||||||
@@ -407,4 +471,12 @@ ul.blocked{margin:0;padding-left:18px}
|
|||||||
.fstat.good{background:rgba(40,140,60,.28)}
|
.fstat.good{background:rgba(40,140,60,.28)}
|
||||||
.fstat.bad{background:rgba(190,50,50,.38);font-weight:700}
|
.fstat.bad{background:rgba(190,50,50,.38);font-weight:700}
|
||||||
.fstat.idle{opacity:.6}
|
.fstat.idle{opacity:.6}
|
||||||
|
/* THE DAY-END DIALOG (Gitea#10). The dialog chrome is play.html's; these are its contents, here
|
||||||
|
because dayEndHtml is here — a panel and its styling stay together. */
|
||||||
|
.dayend-h{font-size:15px;text-transform:none;letter-spacing:0;color:#e6e9ee;margin:0 0 8px}
|
||||||
|
.dayend-t{border-collapse:collapse;margin:9px 0;min-width:210px}
|
||||||
|
.dayend-t td{padding:3px 12px 3px 0;border-top:1px solid #2c333d}
|
||||||
|
.dayend-t tr:first-child td{border-top:0}
|
||||||
|
.dayend-t .num{text-align:right;font-variant-numeric:tabular-nums;font-weight:700;padding-right:0}
|
||||||
|
.dayend-t .you td{color:#8fd6a0}
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -534,6 +534,13 @@ ul.blocked li{padding:2px 0}
|
|||||||
<input id="lb-toolbox" type="checkbox"></label>
|
<input id="lb-toolbox" type="checkbox"></label>
|
||||||
<span class="set-hint" id="lb-toolbox-hint"></span>
|
<span class="set-hint" id="lb-toolbox-hint"></span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="set-row" id="lb-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="lb-tossloco" type="checkbox"></label>
|
||||||
|
<span class="set-hint" id="lb-tossloco-hint"></span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -850,6 +857,13 @@ ul.blocked li{padding:2px 0}
|
|||||||
<input id="ng-toolbox" type="checkbox"></label>
|
<input id="ng-toolbox" type="checkbox"></label>
|
||||||
<span class="set-hint" id="ng-toolbox-hint"></span>
|
<span class="set-hint" id="ng-toolbox-hint"></span>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -863,6 +877,19 @@ ul.blocked li{padding:2px 0}
|
|||||||
</form>
|
</form>
|
||||||
</dialog>
|
</dialog>
|
||||||
|
|
||||||
|
<!-- THE DAY ROLLING OVER (Gitea#10). A Day turns inside the automatic phases, so it happens
|
||||||
|
between one click and the next; the phase banner and the announcement flash both fade before
|
||||||
|
someone reading the board notices them. A modal stops and waits, which is the whole request:
|
||||||
|
"hard to keep track of time". Filled by `dayEndHtml` and opened from `render()`. -->
|
||||||
|
<dialog id="dayenddlg" aria-labelledby="de-title">
|
||||||
|
<form method="dialog">
|
||||||
|
<div id="dayendbody"></div>
|
||||||
|
<menu class="ng-buttons">
|
||||||
|
<button value="ok" id="de-ok" type="submit">Carry on</button>
|
||||||
|
</menu>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
<!-- THE HANDOFF, between `Lobby.Start` and the first Frame.
|
<!-- THE HANDOFF, between `Lobby.Start` and the first Frame.
|
||||||
`beginRemote` used to write "… connecting to the game" into `#presence` — the DISCONNECT banner,
|
`beginRemote` used to write "… connecting to the game" into `#presence` — the DISCONNECT banner,
|
||||||
whose job is `⚠ waiting on Alice`. It worked only because the first render overwrote it, and it
|
whose job is `⚠ waiting on Alice`. It worked only because the first render overwrote it, and it
|
||||||
|
|||||||
+15
-1
@@ -52,6 +52,8 @@ export type Settings = {
|
|||||||
reducedVisibility: boolean;
|
reducedVisibility: boolean;
|
||||||
employeeRotation: boolean;
|
employeeRotation: boolean;
|
||||||
emergencyToolbox: boolean;
|
emergencyToolbox: boolean;
|
||||||
|
/** §6.2 (Gitea#9) — may a Timetabled train be thrown away? An Extra never may, whatever this says. */
|
||||||
|
discardTimetabled: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SETTING_KEYS: readonly (keyof Settings)[] = [
|
export const SETTING_KEYS: readonly (keyof Settings)[] = [
|
||||||
@@ -66,6 +68,7 @@ export const SETTING_KEYS: readonly (keyof Settings)[] = [
|
|||||||
'reducedVisibility',
|
'reducedVisibility',
|
||||||
'employeeRotation',
|
'employeeRotation',
|
||||||
'emergencyToolbox',
|
'emergencyToolbox',
|
||||||
|
'discardTimetabled',
|
||||||
];
|
];
|
||||||
|
|
||||||
export type Preset = {
|
export type Preset = {
|
||||||
@@ -94,6 +97,12 @@ const NO_OPTIONAL_RULES = {
|
|||||||
reducedVisibility: false,
|
reducedVisibility: false,
|
||||||
employeeRotation: false,
|
employeeRotation: false,
|
||||||
emergencyToolbox: false,
|
emergencyToolbox: false,
|
||||||
|
/**
|
||||||
|
* ON in every type (Gitea#9). Jesse's ruling is the rule now, and the setting exists so a table
|
||||||
|
* can put Gitea#6's pressure back rather than so a type can choose for them — the reasoning is
|
||||||
|
* about how long a game runs, which is a dial the table already sets for itself.
|
||||||
|
*/
|
||||||
|
discardTimetabled: true,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
/** Every type deals six now (Jesse, 2026-08-23) — the hand limit is three, so the first turn is a
|
/** Every type deals six now (Jesse, 2026-08-23) — the hand limit is three, so the first turn is a
|
||||||
@@ -211,6 +220,7 @@ export function settingsOf(config: GameConfig): Settings {
|
|||||||
reducedVisibility: config.optionalRules.reducedVisibility,
|
reducedVisibility: config.optionalRules.reducedVisibility,
|
||||||
employeeRotation: config.optionalRules.employeeRotation,
|
employeeRotation: config.optionalRules.employeeRotation,
|
||||||
emergencyToolbox: config.optionalRules.emergencyToolbox,
|
emergencyToolbox: config.optionalRules.emergencyToolbox,
|
||||||
|
discardTimetabled: rules.discardTimetabled,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -258,7 +268,7 @@ export function configFromFrame(f: {
|
|||||||
maxCollisionsPerDay: number;
|
maxCollisionsPerDay: number;
|
||||||
maxCollisionsTotal: number;
|
maxCollisionsTotal: number;
|
||||||
optionalRules: GameConfig['optionalRules'];
|
optionalRules: GameConfig['optionalRules'];
|
||||||
houseRules: { startingHand: StartingHand; extraStart: ExtraStartRule; revenue: RevenueRules };
|
houseRules: { startingHand: StartingHand; extraStart: ExtraStartRule; revenue: RevenueRules; discardTimetabled: boolean };
|
||||||
}): GameConfig {
|
}): GameConfig {
|
||||||
return {
|
return {
|
||||||
mode: f.mode,
|
mode: f.mode,
|
||||||
@@ -272,6 +282,9 @@ export function configFromFrame(f: {
|
|||||||
houseRules: {
|
houseRules: {
|
||||||
startingHand: f.houseRules.startingHand,
|
startingHand: f.houseRules.startingHand,
|
||||||
extraStart: f.houseRules.extraStart,
|
extraStart: f.houseRules.extraStart,
|
||||||
|
// Carried like the rest: this path describes SOMEONE ELSE'S game to a joiner, so a setting
|
||||||
|
// dropped here shows them a rule the table is not playing (§6.2, Gitea#9).
|
||||||
|
discardTimetabled: f.houseRules.discardTimetabled,
|
||||||
revenue: f.houseRules.revenue,
|
revenue: f.houseRules.revenue,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -335,6 +348,7 @@ export function configFromSettings(
|
|||||||
houseRules: {
|
houseRules: {
|
||||||
startingHand: settings.startingHand,
|
startingHand: settings.startingHand,
|
||||||
extraStart: settings.extraStart,
|
extraStart: settings.extraStart,
|
||||||
|
discardTimetabled: settings.discardTimetabled,
|
||||||
revenue: {
|
revenue: {
|
||||||
passengerPerCoach: settings.passengerPerCoach,
|
passengerPerCoach: settings.passengerPerCoach,
|
||||||
freightPerLoad: settings.freightPerLoad,
|
freightPerLoad: settings.freightPerLoad,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* THE RULES BLOCK, driven identically on both screens.
|
* THE RULES BLOCK, driven identically on both screens.
|
||||||
*
|
*
|
||||||
* The lobby (`lobby.ts`) and the solitaire New Game dialog (`main.ts`) ask the same eleven questions.
|
* The lobby (`lobby.ts`) and the solitaire New Game dialog (`main.ts`) ask the same twelve questions.
|
||||||
* They used to ask them in two hand-written copies and had already drifted — the dialog had "where
|
* They used to ask them in two hand-written copies and had already drifted — the dialog had "where
|
||||||
* an Extra may start" and no optional rules, the lobby the reverse — so this module owns reading,
|
* an Extra may start" and no optional rules, the lobby the reverse — so this module owns reading,
|
||||||
* writing, comparing and annotating the block, and each screen supplies only the id prefix its
|
* writing, comparing and annotating the block, and each screen supplies only the id prefix its
|
||||||
@@ -47,10 +47,11 @@ export const FIELDS: readonly Field[] = [
|
|||||||
{ key: 'reducedVisibility', kind: 'checkbox', id: 'visibility' },
|
{ key: 'reducedVisibility', kind: 'checkbox', id: 'visibility' },
|
||||||
{ key: 'employeeRotation', kind: 'checkbox', id: 'rotation' },
|
{ key: 'employeeRotation', kind: 'checkbox', id: 'rotation' },
|
||||||
{ key: 'emergencyToolbox', kind: 'checkbox', id: 'toolbox' },
|
{ key: 'emergencyToolbox', kind: 'checkbox', id: 'toolbox' },
|
||||||
|
{ key: 'discardTimetabled', kind: 'checkbox', id: 'tossloco' },
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* What `play.html` must contain for a screen to be able to ask all eleven questions — the exact
|
* What `play.html` must contain for a screen to be able to ask all twelve questions — the exact
|
||||||
* attribute text, so `test/web.test.ts` can assert it against the built page.
|
* attribute text, so `test/web.test.ts` can assert it against the built page.
|
||||||
*
|
*
|
||||||
* THIS IS THE DRIFT GUARD. The two blocks are generated from one template today; this is what says
|
* THIS IS THE DRIFT GUARD. The two blocks are generated from one template today; this is what says
|
||||||
@@ -71,6 +72,7 @@ export function fieldSelectors(prefix: string): string[] {
|
|||||||
const FIELD_LABELS: Record<keyof Settings, string> = {
|
const FIELD_LABELS: Record<keyof Settings, string> = {
|
||||||
startingHand: 'Starting hand',
|
startingHand: 'Starting hand',
|
||||||
extraStart: 'An Extra may start at',
|
extraStart: 'An Extra may start at',
|
||||||
|
discardTimetabled: 'A Timetabled train may be discarded',
|
||||||
passengerPerCoach: 'Passenger per coach',
|
passengerPerCoach: 'Passenger per coach',
|
||||||
freightPerLoad: 'Freight per load',
|
freightPerLoad: 'Freight per load',
|
||||||
trainPerTransit: 'Train per transit',
|
trainPerTransit: 'Train per transit',
|
||||||
@@ -115,6 +117,7 @@ export function rulesListHtml(config: GameConfig, players: number, days: number)
|
|||||||
return (
|
return (
|
||||||
head +
|
head +
|
||||||
`<h4>Opening</h4><dl>${rows(['startingHand', 'extraStart'])}</dl>` +
|
`<h4>Opening</h4><dl>${rows(['startingHand', 'extraStart'])}</dl>` +
|
||||||
|
`<h4>Train cards</h4><dl>${rows(['discardTimetabled'])}</dl>` +
|
||||||
`<h4>Revenue</h4><dl>${rows(['passengerPerCoach', 'freightPerLoad', 'trainPerTransit'])}</dl>` +
|
`<h4>Revenue</h4><dl>${rows(['passengerPerCoach', 'freightPerLoad', 'trainPerTransit'])}</dl>` +
|
||||||
`<h4>Victory conditions</h4><dl>${rows(['minCombinedRevenue', 'maxCollisionsPerDay', 'maxCollisionsTotal'])}</dl>` +
|
`<h4>Victory conditions</h4><dl>${rows(['minCombinedRevenue', 'maxCollisionsPerDay', 'maxCollisionsTotal'])}</dl>` +
|
||||||
`<h4>Optional rules</h4><dl>${rows(['reducedVisibility', 'employeeRotation', 'emergencyToolbox'])}</dl>`
|
`<h4>Optional rules</h4><dl>${rows(['reducedVisibility', 'employeeRotation', 'emergencyToolbox'])}</dl>`
|
||||||
@@ -205,6 +208,7 @@ export function settingsForm(prefix: string): SettingsForm {
|
|||||||
reducedVisibility: el<HTMLInputElement>('visibility')?.checked === true,
|
reducedVisibility: el<HTMLInputElement>('visibility')?.checked === true,
|
||||||
employeeRotation: el<HTMLInputElement>('rotation')?.checked === true,
|
employeeRotation: el<HTMLInputElement>('rotation')?.checked === true,
|
||||||
emergencyToolbox: el<HTMLInputElement>('toolbox')?.checked === true,
|
emergencyToolbox: el<HTMLInputElement>('toolbox')?.checked === true,
|
||||||
|
discardTimetabled: el<HTMLInputElement>('tossloco')?.checked === true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -220,6 +224,7 @@ export function settingsForm(prefix: string): SettingsForm {
|
|||||||
setChecked('visibility', values.reducedVisibility);
|
setChecked('visibility', values.reducedVisibility);
|
||||||
setChecked('rotation', values.employeeRotation);
|
setChecked('rotation', values.employeeRotation);
|
||||||
setChecked('toolbox', values.emergencyToolbox);
|
setChecked('toolbox', values.emergencyToolbox);
|
||||||
|
setChecked('tossloco', values.discardTimetabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
function setNumber(id: string, value: number): void {
|
function setNumber(id: string, value: number): void {
|
||||||
|
|||||||
+94
-27
@@ -262,19 +262,20 @@ describe('Local Operations: drawing (§6.2)', () => {
|
|||||||
assert.equal(check(s, 0, { type: 'draw.end' }), null, 'the turn cannot be ended even at the limit');
|
assert.equal(check(s, 0, { type: 'draw.end' }), null, 'the turn cannot be ended even at the limit');
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('a train card is never discarded (Gitea#6)', () => {
|
describe('which train cards may be discarded (Gitea#9, superseding Gitea#6)', () => {
|
||||||
/**
|
/**
|
||||||
* Jesse's ruling, v0.4.9e playtest: "Players are not allowed to discard Train cards. They may
|
* Gitea#6's ruling, v0.4.9e playtest, was that NO train card may be discarded. Gitea#9 narrows
|
||||||
* keep the card in their hand for multiple stages and even multiple days, but they may not
|
* it — Jesse, 2026-08-24: "Timetabled trains are at the choice of the player: they can either
|
||||||
* discard it. If a player has three train cards in their hand, and they draw a fourth, then they
|
* play or discard. If someone else wants to pick it up, they are more than able to. The reason:
|
||||||
* must play one of those cards."
|
* I don't want, if you decide to play a game longer than five days, to decide that maybe there
|
||||||
|
* are too many trains, the stations are jammed, and the railroad doesn't need any more."
|
||||||
*
|
*
|
||||||
* Extras count too — an Extra is a train, even though it runs once and ends in the Salvage Yard
|
* So a Timetabled train is discardable, an EXTRA still is not — it never joins the timetable, so
|
||||||
* where a Timetabled card joins the timetable for the rest of the game.
|
* it cannot be what jams it — and whether the Timetabled half applies is a New Game setting,
|
||||||
|
* because the reasoning is about long games and a five-Day game may want Gitea#6's pressure.
|
||||||
*
|
*
|
||||||
* Note there is no new FORCING mechanism, and deliberately so: the corner is what the two
|
* Note there is still no FORCING mechanism, and deliberately so: the corner is what the two
|
||||||
* existing rules produce together. Nothing discardable plus "you may not end the turn over the
|
* existing rules produce together whenever the setting is off.
|
||||||
* limit" leaves exactly one legal way on, and playing a train is unconditionally legal.
|
|
||||||
*/
|
*/
|
||||||
const handOf = (s: GameState, kinds: string[]): string[] => {
|
const handOf = (s: GameState, kinds: string[]): string[] => {
|
||||||
// Hand-pick cards of the wanted kinds straight out of the catalogue, so the test does not
|
// Hand-pick cards of the wanted kinds straight out of the catalogue, so the test does not
|
||||||
@@ -292,35 +293,74 @@ describe('Local Operations: drawing (§6.2)', () => {
|
|||||||
return picked;
|
return picked;
|
||||||
};
|
};
|
||||||
|
|
||||||
it('refuses the discard, for a Timetabled train and for an Extra alike', () => {
|
/** The same game with the setting turned off — Gitea#6's rule, still reachable. */
|
||||||
|
const strictGame = (): GameState =>
|
||||||
|
createGame({
|
||||||
|
id: 'g',
|
||||||
|
seed: 77,
|
||||||
|
config: { ...config, houseRules: { ...(config.houseRules ?? {}), discardTimetabled: false } },
|
||||||
|
playerNames: ['Jesse'],
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets a Timetabled train be discarded, and still refuses an Extra', () => {
|
||||||
const s = game();
|
const s = game();
|
||||||
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
|
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
|
||||||
const [timetabled, extra, track] = handOf(s, ['timetabledTrain', 'extraTrain', 'track']);
|
const [timetabled, extra, track] = handOf(s, ['timetabledTrain', 'extraTrain', 'track']);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
check(s, 0, { type: 'card.discard', cardId: timetabled!, toSlot: 0 }),
|
check(s, 0, { type: 'card.discard', cardId: timetabled!, toSlot: 0 }),
|
||||||
'TRAINS_ARE_NEVER_DISCARDED',
|
null,
|
||||||
|
'Gitea#9 allows this and it was refused',
|
||||||
);
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
check(s, 0, { type: 'card.discard', cardId: extra!, toSlot: 0 }),
|
check(s, 0, { type: 'card.discard', cardId: extra!, toSlot: 0 }),
|
||||||
'TRAINS_ARE_NEVER_DISCARDED',
|
'TRAINS_ARE_NEVER_DISCARDED',
|
||||||
|
'an Extra never joins the timetable, so Gitea#9 does not reach it',
|
||||||
);
|
);
|
||||||
// And everything else is still discardable — the rule is about trains, not about discarding.
|
|
||||||
assert.equal(check(s, 0, { type: 'card.discard', cardId: track!, toSlot: 0 }), null);
|
assert.equal(check(s, 0, { type: 'card.discard', cardId: track!, toSlot: 0 }), null);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('never offers the discard, so the bot needs no rule of its own', () => {
|
it('puts the discarded train where a rival can pick it up', () => {
|
||||||
|
// The other half of the ruling — "if someone else wants to pick it up, they are more than able
|
||||||
|
// to" — needed no machinery, because a discard already goes face-up onto a Department pile.
|
||||||
const s = game();
|
const s = game();
|
||||||
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
|
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
|
||||||
const [timetabled] = handOf(s, ['timetabledTrain', 'track']);
|
const [timetabled] = handOf(s, ['timetabledTrain', 'track']);
|
||||||
const offered = legalActions(s, 0).filter(
|
assert.ok(applyIntent(s, 0, { type: 'card.discard', cardId: timetabled!, toSlot: 1 }).ok);
|
||||||
(i) => i.type === 'card.discard' && i.cardId === timetabled,
|
const pile = s.decks.departments[1]!;
|
||||||
);
|
assert.equal(pile[pile.length - 1], timetabled, 'the train is not face-up on the pile');
|
||||||
assert.deepEqual(offered, [], 'a train discard was offered as a legal action');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('leaves PLAYING a train as the only way out of a hand of four trains', () => {
|
it('offers the discard as a legal action, so the bot can take it', () => {
|
||||||
const s = game();
|
const s = game();
|
||||||
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
|
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
|
||||||
|
const [timetabled, extra] = handOf(s, ['timetabledTrain', 'extraTrain', 'track']);
|
||||||
|
const offered = legalActions(s, 0).filter((i) => i.type === 'card.discard');
|
||||||
|
assert.ok(
|
||||||
|
offered.some((i) => i.type === 'card.discard' && i.cardId === timetabled),
|
||||||
|
'a Timetabled train was not offered as a discard',
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
!offered.some((i) => i.type === 'card.discard' && i.cardId === extra),
|
||||||
|
'an Extra was offered as a discard',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps Gitea#6 reachable when the setting is off', () => {
|
||||||
|
const s = strictGame();
|
||||||
|
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
|
||||||
|
const [timetabled, extra, track] = handOf(s, ['timetabledTrain', 'extraTrain', 'track']);
|
||||||
|
for (const id of [timetabled!, extra!]) {
|
||||||
|
assert.equal(
|
||||||
|
check(s, 0, { type: 'card.discard', cardId: id, toSlot: 0 }),
|
||||||
|
'TRAINS_ARE_NEVER_DISCARDED',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert.equal(check(s, 0, { type: 'card.discard', cardId: track!, toSlot: 0 }), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves PLAYING a train as the only way out of a hand of four, setting off', () => {
|
||||||
|
const s = strictGame();
|
||||||
|
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
|
||||||
const four = handOf(s, ['timetabledTrain', 'timetabledTrain', 'timetabledTrain', 'extraTrain']);
|
const four = handOf(s, ['timetabledTrain', 'timetabledTrain', 'timetabledTrain', 'extraTrain']);
|
||||||
assert.ok(four.length > HAND_LIMIT, 'this test needs a hand over the limit');
|
assert.ok(four.length > HAND_LIMIT, 'this test needs a hand over the limit');
|
||||||
|
|
||||||
@@ -337,11 +377,26 @@ describe('Local Operations: drawing (§6.2)', () => {
|
|||||||
assert.equal(check(s, 0, { type: 'draw.end' }), null, 'playing a train did not free the turn');
|
assert.equal(check(s, 0, { type: 'draw.end' }), null, 'playing a train did not free the turn');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('a hand of four Extras is the corner that survives Gitea#9 with the setting ON', () => {
|
||||||
|
// Gitea#9 does not reach an Extra, so the deadlock-that-is-not-a-deadlock is still real in a
|
||||||
|
// default game — worth pinning, since it is now the ONLY way to reach it.
|
||||||
|
const s = game();
|
||||||
|
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
|
||||||
|
const four = handOf(s, ['extraTrain', 'extraTrain', 'extraTrain', 'extraTrain']);
|
||||||
|
assert.equal(check(s, 0, { type: 'draw.end' }), 'HAND_LIMIT');
|
||||||
|
for (const id of four) {
|
||||||
|
assert.equal(check(s, 0, { type: 'card.discard', cardId: id, toSlot: 0 }), 'TRAINS_ARE_NEVER_DISCARDED');
|
||||||
|
}
|
||||||
|
assert.ok(applyIntent(s, 0, { type: 'card.play', cardId: four[0]! }).ok);
|
||||||
|
assert.equal(check(s, 0, { type: 'draw.end' }), null);
|
||||||
|
});
|
||||||
|
|
||||||
it('lets a train be held across Stages and into the next Day', () => {
|
it('lets a train be held across Stages and into the next Day', () => {
|
||||||
// "They may keep the card in their hand for multiple stages and even multiple days." Nothing
|
// "They may keep the card in their hand for multiple stages and even multiple days." Nothing
|
||||||
// sweeps a hand at a Stage or Day boundary, and this is what says so out loud.
|
// sweeps a hand at a Stage or Day boundary, and this is what says so out loud. An Extra is
|
||||||
|
// used, because it is the card that still cannot be got rid of any other way.
|
||||||
const s = game();
|
const s = game();
|
||||||
const [timetabled] = handOf(s, ['timetabledTrain', 'track']);
|
const [extra] = handOf(s, ['extraTrain', 'track']);
|
||||||
const startDay = s.clock.day;
|
const startDay = s.clock.day;
|
||||||
|
|
||||||
// Play out Stages by taking whatever ends the current turn, until the Day turns over.
|
// Play out Stages by taking whatever ends the current turn, until the Day turns over.
|
||||||
@@ -357,24 +412,36 @@ describe('Local Operations: drawing (§6.2)', () => {
|
|||||||
|
|
||||||
assert.ok(s.clock.day > startDay, `the Day never turned (stopped at ${s.clock.day}/${s.clock.stage})`);
|
assert.ok(s.clock.day > startDay, `the Day never turned (stopped at ${s.clock.day}/${s.clock.stage})`);
|
||||||
assert.ok(
|
assert.ok(
|
||||||
(s.decks.hands.get(0) ?? []).includes(timetabled!),
|
(s.decks.hands.get(0) ?? []).includes(extra!),
|
||||||
'the train did not survive being held into the next Day',
|
'the train did not survive being held into the next Day',
|
||||||
);
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
check(s, 0, { type: 'card.discard', cardId: timetabled!, toSlot: 0 }),
|
check(s, 0, { type: 'card.discard', cardId: extra!, toSlot: 0 }),
|
||||||
'TRAINS_ARE_NEVER_DISCARDED',
|
'TRAINS_ARE_NEVER_DISCARDED',
|
||||||
'a Day boundary made a train discardable',
|
'a Day boundary made an Extra discardable',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('tells the player on the card itself, and on the button when every card is a train', () => {
|
it('tells the player on the card itself which of the two rules applies', () => {
|
||||||
// The Gitea#2 lesson: a rule the player cannot see is a board with nothing to click and no
|
// The Gitea#2 lesson: a rule the player cannot see is a board with nothing to click and no
|
||||||
// reason given.
|
// reason given. Since Gitea#9 there are TWO reasons, so the card has to say which.
|
||||||
const s = game();
|
const s = game();
|
||||||
handOf(s, ['timetabledTrain', 'extraTrain', 'track']);
|
handOf(s, ['timetabledTrain', 'extraTrain', 'track']);
|
||||||
const f = snapshot(s, [], null);
|
const f = snapshot(s, [], null);
|
||||||
// `hand` is reversed for display, so compare as a set rather than by position.
|
// `hand` is reversed for display, so compare as a set rather than by position.
|
||||||
assert.deepEqual([...f.handDiscardable].sort(), [false, false, true]);
|
assert.deepEqual([...f.handDiscardable].sort(), [false, true, true]);
|
||||||
|
const said = f.handKeepWhy.filter((w): w is string => w !== null);
|
||||||
|
assert.equal(said.length, 1, 'exactly one card in this hand may not be discarded');
|
||||||
|
assert.match(said[0]!, /An Extra is never discarded/);
|
||||||
|
|
||||||
|
const strict = strictGame();
|
||||||
|
handOf(strict, ['timetabledTrain', 'extraTrain', 'track']);
|
||||||
|
const sf = snapshot(strict, [], null);
|
||||||
|
assert.deepEqual([...sf.handDiscardable].sort(), [false, false, true]);
|
||||||
|
assert.ok(
|
||||||
|
sf.handKeepWhy.some((w) => w !== null && /never discarded in this game/.test(w)),
|
||||||
|
'the setting being off is not explained on the card',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+88
-1
@@ -11,8 +11,10 @@ import assert from 'node:assert/strict';
|
|||||||
import { pump } from '../src/engine/advance.ts';
|
import { pump } from '../src/engine/advance.ts';
|
||||||
import { DEFAULT_MAX_COLLISIONS_PER_DAY, DEFAULT_MAX_COLLISIONS_TOTAL, collectiveRevenueFloor } from '../src/engine/content.ts';
|
import { DEFAULT_MAX_COLLISIONS_PER_DAY, DEFAULT_MAX_COLLISIONS_TOTAL, collectiveRevenueFloor } from '../src/engine/content.ts';
|
||||||
import type { GameEvent } from '../src/engine/events.ts';
|
import type { GameEvent } from '../src/engine/events.ts';
|
||||||
|
import { areaOf } from '../src/engine/apply.ts';
|
||||||
import { createGame } from '../src/engine/setup.ts';
|
import { createGame } from '../src/engine/setup.ts';
|
||||||
import type { GameConfig } from '../src/engine/state.ts';
|
import { coordKey } from '../src/engine/state.ts';
|
||||||
|
import type { GameConfig, GameState } from '../src/engine/state.ts';
|
||||||
import { developerBot, playGame } from '../src/sim/bot.ts';
|
import { developerBot, playGame } from '../src/sim/bot.ts';
|
||||||
import { impediments, isVisible, narrate, phaseLabel } from '../src/sim/narrate.ts';
|
import { impediments, isVisible, narrate, phaseLabel } from '../src/sim/narrate.ts';
|
||||||
import { compress, rehydrateCells, record, renderHtml } from '../src/sim/replay.ts';
|
import { compress, rehydrateCells, record, renderHtml } from '../src/sim/replay.ts';
|
||||||
@@ -138,6 +140,91 @@ describe('impediments', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gitea#2 — "four porters, two passengers on the platform, and I never get the chance to work them."
|
||||||
|
*
|
||||||
|
* The engine was faithful at every step; what was missing was any way to SEE why. A Porter action
|
||||||
|
* that cannot be taken is simply absent from the menu, and this panel — the one that answers "why is
|
||||||
|
* nothing moving?" — opened with `f.kind !== 'freight'`, so a platform had never had anything to say
|
||||||
|
* for itself at all.
|
||||||
|
*/
|
||||||
|
describe('a blocked platform says why (Gitea#2)', () => {
|
||||||
|
/** Raise the Whistle Post to a working Terminal: the tier's printed numbers, applied directly. */
|
||||||
|
function platform(s: GameState) {
|
||||||
|
const area = areaOf(s, 0);
|
||||||
|
area.tier = 'terminal';
|
||||||
|
const card = area.grid.get(coordKey(area.officeCoord))!;
|
||||||
|
const f = card.facility!;
|
||||||
|
f.allows = { outbound: true, inbound: true };
|
||||||
|
f.porters = 3;
|
||||||
|
f.capacity = { outbound: 3, inbound: 3 };
|
||||||
|
return { area, f };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A tray standing on an A/D track at the Office, carrying whatever it is given. */
|
||||||
|
function atOffice(s: GameState, consist: { type: 'coach'; loaded: boolean; origin?: number }[]): void {
|
||||||
|
const area = areaOf(s, 0);
|
||||||
|
const id = s.freeTrays.pop()!;
|
||||||
|
s.trays.set(id, {
|
||||||
|
id, trainNumber: null, trainIsExtra: false, engineAt: 0, consist,
|
||||||
|
direction: 'east', position: { at: 'grid', seat: 0, coord: area.officeCoord }, movesUsed: 0,
|
||||||
|
});
|
||||||
|
area.adOccupancy.push(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
it('reports passengers standing on a platform with no train to take them', () => {
|
||||||
|
// The whole of the bug's second half: before this, `impediments` returned an EMPTY list here.
|
||||||
|
const s = createGame({ id: 'g', seed: 5, config, playerNames: ['p'] });
|
||||||
|
const { f } = platform(s);
|
||||||
|
f.outboundBox = [{ type: 'coach', loaded: true }];
|
||||||
|
const found = impediments(s, 0);
|
||||||
|
const platformRow = found.find((b) => /platform/.test(b.why));
|
||||||
|
assert.ok(platformRow, `nothing reported for the platform:\n${JSON.stringify(found, null, 2)}`);
|
||||||
|
assert.match(platformRow.why, /passengers waiting, no train at the platform/);
|
||||||
|
assert.equal(platformRow.severity, 'waiting');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('names the Office by its tier rather than the word "facility"', () => {
|
||||||
|
// A Passenger Facility rides on the `office` card, so the freight branch's `geometry.facility`
|
||||||
|
// is not there to read and every passenger row read `facility 0,0` next to `mineTipple 1,-3`.
|
||||||
|
const s = createGame({ id: 'g', seed: 5, config, playerNames: ['p'] });
|
||||||
|
const { f } = platform(s);
|
||||||
|
f.outboundBox = [{ type: 'coach', loaded: true }];
|
||||||
|
const row = impediments(s, 0).find((b) => /platform/.test(b.why))!;
|
||||||
|
assert.match(row.where, /^terminal /, `the Office is unnamed: ${row.where}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('explains the coach shortage that made the game look broken', () => {
|
||||||
|
// The reported state: a train in with passengers to set down, red slots free, four porters —
|
||||||
|
// and §9.2 needs a white coach out of the Division Yard to swap in. There was none, with eight
|
||||||
|
// more sitting in the Classification Yard that §2.2 returns only when the Division Yard is BARE.
|
||||||
|
const s = createGame({ id: 'g', seed: 5, config, playerNames: ['p'] });
|
||||||
|
const { f } = platform(s);
|
||||||
|
atOffice(s, [{ type: 'coach', loaded: true, origin: 1 }]);
|
||||||
|
s.yards.divisionYard = s.yards.divisionYard.filter((c) => !(c.type === 'coach' && !c.loaded));
|
||||||
|
s.yards.classificationYard = [
|
||||||
|
{ type: 'coach', loaded: false },
|
||||||
|
{ type: 'coach', loaded: false },
|
||||||
|
];
|
||||||
|
const row = impediments(s, 0).find((b) => /§9\.2/.test(b.why));
|
||||||
|
assert.ok(row, `the coach shortage was not explained:\n${JSON.stringify(impediments(s, 0), null, 2)}`);
|
||||||
|
assert.equal(row.severity, 'stuck', 'a train that cannot be emptied is stuck, not merely waiting');
|
||||||
|
assert.match(row.why, /2 coaches are in the Classification Yard/, `where the coaches are is not said: ${row.why}`);
|
||||||
|
assert.match(row.why, /Classification returns only when the Division Yard is bare/);
|
||||||
|
assert.ok(f.inboundBox.length === 0, 'the red slots were free — the shortage is the only cause');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('says nothing about a platform that is working fine', () => {
|
||||||
|
// Passengers waiting AND a train with an empty coach to take them: no impediment.
|
||||||
|
const s = createGame({ id: 'g', seed: 5, config, playerNames: ['p'] });
|
||||||
|
const { f } = platform(s);
|
||||||
|
f.outboundBox = [{ type: 'coach', loaded: true }];
|
||||||
|
atOffice(s, [{ type: 'coach', loaded: false }]);
|
||||||
|
const found = impediments(s, 0).filter((b) => /platform|§9\.2|Porters/.test(b.why));
|
||||||
|
assert.deepEqual(found, [], `a working platform reported an impediment:\n${JSON.stringify(found, null, 2)}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('replay recording', () => {
|
describe('replay recording', () => {
|
||||||
const rec = record(1234, 'standard');
|
const rec = record(1234, 'standard');
|
||||||
|
|
||||||
|
|||||||
@@ -74,6 +74,8 @@ const west = (s: GameState, n = 2): GridCoord => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const boxcar = (loaded = false): RollingStock => ({ type: 'boxcar', loaded });
|
const boxcar = (loaded = false): RollingStock => ({ type: 'boxcar', loaded });
|
||||||
|
/** As `ROLLING_STOCK_SUPPLY` mints them: there is no empty caboose in the game. */
|
||||||
|
const caboose = (): RollingStock => ({ type: 'caboose', loaded: true });
|
||||||
const coach = (loaded = false): RollingStock => ({ type: 'coach', loaded });
|
const coach = (loaded = false): RollingStock => ({ type: 'coach', loaded });
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -141,6 +143,23 @@ describe('§7 — what a train may couple', () => {
|
|||||||
'EMPTIES_ONLY',
|
'EMPTIES_ONLY',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('X22 Pee-Dee may still couple a caboose, which is not a load (Gitea#8)', () => {
|
||||||
|
// Every caboose in the game is minted `loaded: true` because the supply table's loaded/empty
|
||||||
|
// split doubles as a piece count. Taken literally that left the per-diem train unable to pick
|
||||||
|
// up ANY caboose, its own included: set it out at the end of a sweep and it was stranded there.
|
||||||
|
const s = game();
|
||||||
|
switching(s, 22, true, [], [caboose()]);
|
||||||
|
assert.equal(check(s, 0, { type: 'switch.move', trayId: 't', to: west(s), reverse: false }), null);
|
||||||
|
|
||||||
|
// The restriction itself is untouched — a loaded car alongside the caboose still refuses.
|
||||||
|
const withLoad = game();
|
||||||
|
switching(withLoad, 22, true, [], [caboose(), boxcar(true)]);
|
||||||
|
assert.equal(
|
||||||
|
check(withLoad, 0, { type: 'switch.move', trayId: 't', to: west(withLoad), reverse: false }),
|
||||||
|
'EMPTIES_ONLY',
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('§7 — one freight car per location (trains 3/4)', () => {
|
describe('§7 — one freight car per location (trains 3/4)', () => {
|
||||||
|
|||||||
+98
-2
@@ -22,10 +22,11 @@ import { cardDescription, cardName, describeIntent, variantLabel } from '../src/
|
|||||||
import { variantsFor } from '../src/engine/track.ts';
|
import { variantsFor } from '../src/engine/track.ts';
|
||||||
import { divisionSvg, officeSvg } from '../src/sim/board-svg.ts';
|
import { divisionSvg, officeSvg } from '../src/sim/board-svg.ts';
|
||||||
import { ENHANCEMENT_RULES } from '../src/engine/content.ts';
|
import { ENHANCEMENT_RULES } from '../src/engine/content.ts';
|
||||||
import { facilitiesHtml, timetableHtml } from '../src/web/panels.ts';
|
import { dayEndHtml, facilitiesHtml, timetableHtml } from '../src/web/panels.ts';
|
||||||
import { turnChartHtml } from '../src/sim/turnchart.ts';
|
import { turnChartHtml } from '../src/sim/turnchart.ts';
|
||||||
import { fieldSelectors } from '../src/web/settings-form.ts';
|
import { fieldSelectors } from '../src/web/settings-form.ts';
|
||||||
import { record, renderHtml } from '../src/sim/replay.ts';
|
import { record, renderHtml } from '../src/sim/replay.ts';
|
||||||
|
import type { Frame } from '../src/sim/view.ts';
|
||||||
import { snapshot } from '../src/sim/view.ts';
|
import { snapshot } from '../src/sim/view.ts';
|
||||||
import { createGame as createEngineGame } from '../src/engine/setup.ts';
|
import { createGame as createEngineGame } from '../src/engine/setup.ts';
|
||||||
import {
|
import {
|
||||||
@@ -3090,6 +3091,84 @@ describe('the three places a game is drawn stay in step', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('the Day rolling over says so (Gitea#10)', () => {
|
||||||
|
// "As the game rolls off the end of the day, you get a dialog saying such. Hard to keep track of
|
||||||
|
// time." A Day turns inside the phases that run themselves, so it passes between one click and
|
||||||
|
// the next — the phase banner and the announcement flash are both gone in a few seconds.
|
||||||
|
const frameAt = (day: number, days = 5): Frame => {
|
||||||
|
const s = createEngineGame({
|
||||||
|
id: 'dayend', seed: 4021,
|
||||||
|
config: {
|
||||||
|
mode: 'solitaire', days, minCombinedRevenue: 12, maxCollisionsPerDay: 0, maxCollisionsTotal: 0, pvpCardsAllowed: false,
|
||||||
|
optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false },
|
||||||
|
},
|
||||||
|
playerNames: ['Solitaire'],
|
||||||
|
});
|
||||||
|
s.clock.day = day;
|
||||||
|
return snapshot(s, [], null);
|
||||||
|
};
|
||||||
|
|
||||||
|
it('names the Day that ENDED, not the one starting', () => {
|
||||||
|
// Written from the frame after the rollover, so an off-by-one here would congratulate a player
|
||||||
|
// on finishing a Day they have not played yet.
|
||||||
|
const html = dayEndHtml(frameAt(3));
|
||||||
|
assert.ok(html.includes('Day 2 has ended'), `wrong Day named:\n${html}`);
|
||||||
|
assert.ok(html.includes('Day 3 of 5'), 'the Day now beginning is not named');
|
||||||
|
assert.ok(html.includes('3 Days left'), `the Days remaining are wrong:\n${html}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('counts the last Day as the last Day rather than promising more', () => {
|
||||||
|
const html = dayEndHtml(frameAt(6));
|
||||||
|
assert.ok(html.includes('Day 5 has ended'), 'the final Day is misnamed');
|
||||||
|
assert.ok(html.includes('last Day on the timetable'), `still offering Days to run:\n${html}`);
|
||||||
|
assert.ok(!html.includes('Days left'), 'promises more Days after the last one');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('scores the table against the COMBINED target, not one player against it', () => {
|
||||||
|
// `minCombinedRevenue` is a floor for the whole table. Showing one player's Revenue against a
|
||||||
|
// four-player target reads as hopeless when the table may be well ahead.
|
||||||
|
const f = frameAt(2);
|
||||||
|
f.players = [
|
||||||
|
{ index: 0, seat: 0, name: 'Ada', revenue: 4, hand: 3 },
|
||||||
|
{ index: 1, seat: 1, name: 'Bo', revenue: 7, hand: 2 },
|
||||||
|
];
|
||||||
|
f.viewer = 0;
|
||||||
|
const html = dayEndHtml(f);
|
||||||
|
assert.ok(html.includes('<b>11</b>'), `combined Revenue is not 4 + 7:\n${html}`);
|
||||||
|
assert.ok(html.includes('<b>12</b>'), 'the target is not shown');
|
||||||
|
// Revenue order, so the leader is first: Bo (7) above Ada (4).
|
||||||
|
assert.ok(html.indexOf('Bo') < html.indexOf('Ada'), 'the standings are not in Revenue order');
|
||||||
|
assert.ok(html.includes('(you)'), 'the viewer is not marked in the standings');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('says nothing about collisions in a game that is not scored on them', () => {
|
||||||
|
// §3.4's collision checks run in competitive and co-op ONLY, and `0` on a dial turns that check
|
||||||
|
// off besides. A solitaire game carries the default dials and enforces neither, so a collision
|
||||||
|
// budget on screen there would be a rule this game does not have.
|
||||||
|
const solo = frameAt(2);
|
||||||
|
assert.ok(!dayEndHtml(solo).includes('Collision'), 'reports a collision budget in solitaire');
|
||||||
|
|
||||||
|
const coop = frameAt(2);
|
||||||
|
coop.mode = 'coop';
|
||||||
|
coop.maxCollisionsTotal = 3;
|
||||||
|
coop.collisionsTotal = 1;
|
||||||
|
assert.ok(dayEndHtml(coop).includes('Collision'), 'hides collisions in a game scored on them');
|
||||||
|
|
||||||
|
const noDials = frameAt(2);
|
||||||
|
noDials.mode = 'coop';
|
||||||
|
noDials.maxCollisionsTotal = 0;
|
||||||
|
noDials.maxCollisionsPerDay = 0;
|
||||||
|
assert.ok(!dayEndHtml(noDials).includes('Collision'), 'reports collisions with both dials off');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives the page the dialog to fill', () => {
|
||||||
|
const html = readFileSync(join(dist, 'play.html'), 'utf8');
|
||||||
|
for (const id of ['dayenddlg', 'dayendbody']) {
|
||||||
|
assert.ok(html.includes(`id="${id}"`), `play.html has no #${id}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('every file the build needs is actually in the repo (regression)', () => {
|
describe('every file the build needs is actually in the repo (regression)', () => {
|
||||||
it('does not gitignore a source page', () => {
|
it('does not gitignore a source page', () => {
|
||||||
// REGRESSION. `.gitignore` carried `replay*.html` to catch the throwaway files generated at the
|
// REGRESSION. `.gitignore` carried `replay*.html` to catch the throwaway files generated at the
|
||||||
@@ -3569,7 +3648,7 @@ describe('the New Game dialog', () => {
|
|||||||
* A seed alone stopped naming a game the moment the opening hand and the revenue rates became
|
* A seed alone stopped naming a game the moment the opening hand and the revenue rates became
|
||||||
* settings, so what this really pins is that all of them ride in the URL and come back out.
|
* settings, so what this really pins is that all of them ride in the URL and come back out.
|
||||||
*
|
*
|
||||||
* REBUILT 2026-08-23 with the five game types. The dialog and the lobby now ask the same eleven
|
* REBUILT 2026-08-23 with the five game types. The dialog and the lobby now ask the same twelve
|
||||||
* questions through `settings-form.ts`, which addresses its radio groups by NAME through the
|
* questions through `settings-form.ts`, which addresses its radio groups by NAME through the
|
||||||
* DOCUMENT — so the stub keeps one set of groups and answers for both the document and the dialog.
|
* DOCUMENT — so the stub keeps one set of groups and answers for both the document and the dialog.
|
||||||
*/
|
*/
|
||||||
@@ -3681,6 +3760,7 @@ describe('the New Game dialog', () => {
|
|||||||
transit: els.get('ng-transit')!['value'],
|
transit: els.get('ng-transit')!['value'],
|
||||||
days: els.get('ng-days')!['value'],
|
days: els.get('ng-days')!['value'],
|
||||||
minrev: els.get('ng-minrev')!['value'],
|
minrev: els.get('ng-minrev')!['value'],
|
||||||
|
tossloco: els.get('ng-tossloco')!['checked'],
|
||||||
});
|
});
|
||||||
|
|
||||||
it('opens on the rules in play, so a second game can be dealt to compare with the first', async () => {
|
it('opens on the rules in play, so a second game can be dealt to compare with the first', async () => {
|
||||||
@@ -3699,6 +3779,22 @@ describe('the New Game dialog', () => {
|
|||||||
assert.equal(form.hand, 'sixRandom', 'the opening hand in play was not preselected');
|
assert.equal(form.hand, 'sixRandom', 'the opening hand in play was not preselected');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('carries §6.2 in the URL, written only when it is OFF (Gitea#9)', async () => {
|
||||||
|
// The setting defaults ON, so a link that spelled out `toss=1` every time would say nothing and
|
||||||
|
// cost a parameter — the same reason the optional rules are written only when they are on. What
|
||||||
|
// has to survive the trip is therefore the OFF case, which is the one that changes the game.
|
||||||
|
const on = await load('?seed=430');
|
||||||
|
(on.els.get('newgame')!['onclick'] as () => void)();
|
||||||
|
assert.equal(readForm(on.els, on.groups).tossloco, true, 'a default game did not allow the discard');
|
||||||
|
|
||||||
|
const off = await load('?seed=430&toss=0');
|
||||||
|
(off.els.get('newgame')!['onclick'] as () => void)();
|
||||||
|
const form = readForm(off.els, off.groups);
|
||||||
|
assert.equal(form.tossloco, false, '?toss=0 did not reach the dialog');
|
||||||
|
// And it counts as a rule change, so the game is no longer the named type.
|
||||||
|
assert.equal(form.type, 'custom', 'turning the rule off still read as Solitaire');
|
||||||
|
});
|
||||||
|
|
||||||
it('reopens on Solitaire when the game in play is one, and on Custom when it was tuned', async () => {
|
it('reopens on Solitaire when the game in play is one, and on Custom when it was tuned', async () => {
|
||||||
// The type is DERIVED (`presets.ts`) rather than remembered, so what the dialog says a game is
|
// The type is DERIVED (`presets.ts`) rather than remembered, so what the dialog says a game is
|
||||||
// has to follow from its numbers — including a game whose numbers were hand-edited into the URL.
|
// has to follow from its numbers — including a game whose numbers were hand-edited into the URL.
|
||||||
|
|||||||
Reference in New Issue
Block a user