v0.4.9g — a caboose is not a load, a Day that says it ended, and a train you may throw away

The v0.7.1 bugfix set ported to the playtest line: Gitea#8, #10, #9 and #2. Reasoning, and what
differs from main, in CHANGELOG.md.

- Gitea#8: X22 Pee-Dee refused every caboose, including the one it was made up with. A caboose
  carries the crew, not freight, so it is never a load.
- Gitea#10: a dialog when the Day rolls over, carrying the standings, the Days left and your Revenue
  against the target with the pace. Suppressed on the first frame, on Undo stepping back across a
  rollover, and on the Day the game ends.
- Gitea#9, superseding Gitea#6 from v0.4.9f: a Timetabled train may be discarded, an Extra may not.
  This line takes the plain rule — main has it as a New Game setting.
- Gitea#2: the shortage stays, by Jesse's ruling. A blocked platform now gives its reason instead of
  simply having no button.

Three things are written for this line rather than ported: the Timetabled discard has no setting and
no URL parameter, the Day-end dialog reads f.objective (this line's per-player target) where main
reads its combined victory dials, and Frame gained the viewer index the dialog needs to mark your
row. 636 tests pass; no published replay was retired.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FLnYR4XtXQNamYJXGYT8oC
This commit is contained in:
Jesse.Markowitz
2026-08-25 10:47:10 -04:00
co-authored by Claude Opus 5
parent e255076b5c
commit c8d5624558
15 changed files with 867 additions and 107 deletions
+101
View File
@@ -19,6 +19,107 @@ page as `v0.1.0 · <sha> · <date>`, so what is deployed can always be identifie
---
## 0.4.9g — 2026-08-25
Four more off the Gitea tracker, all of them things a player saw at the board: **#8** a per-diem train
that could not couple a caboose, **#10** no way to tell when the Day turned over, **#9** a ruling that
lets a Timetabled train be thrown away — which supersedes **#6**, shipped three days ago in 0.4.9f —
and **#2**, which turned out not to be an engine bug at all, so what is fixed there is that the game
now says why.
The same four ship as **0.7.1** on the main line. The full reasoning for every item is in that line's
CHANGELOG; this entry records what reached the playtest build and what differs.
### Gitea#8 — a caboose is not a load
X22 Pee-Dee, whose card reads "may only pick up MTs", refused every caboose — including the one it was
made up with, so setting it out at the end of a sweep stranded the train. All six cabooses are minted
`loaded: true`, because §2.2's "coloured is loaded, white is empty" doubles as a PIECE COUNT in the
supply table and there is no white caboose to make a train up from. Exactly one read of the flag took
that literally. A caboose carries the crew, not freight, so it is never a load; the restriction itself
is untouched, and a loaded car alongside the caboose still refuses. The card now says so
("A caboose is not a load"), because a player reading "EMPTIES ONLY" cannot know which reading the
game took.
### Gitea#10 — a dialog when the Day rolls over
"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 phase banner that announces it is gone in 2.6
seconds. A modal stops and waits, which is the whole request. It carries the Day that ended, the
standings in Revenue order, the Days left to run, and your Revenue against the target with the pace.
Suppressed on the first frame (arriving in a game already on Day 3 is not Day 2 ending), on Undo
stepping back across a rollover, and on the Day the game ends — the outcome panel is the thing to
read then.
### Gitea#9 — a Timetabled train may be thrown away
**This supersedes Gitea#6 above, which shipped in 0.4.9f three days ago.** 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."
An **Extra** is still never discardable: it never joins the timetable, so it can never be what jams
it. The second half of the ruling needed no code — a discard already goes face-up onto a Department
pile, which is exactly where a rival draws from.
**Gitea#6's corner survives, narrowed.** A hand with no discardable card still has exactly one way on
— play one — with nothing in the engine computing "you must play a train". The hand that reaches it
is now four Extras rather than any four trains.
### Gitea#2 — why nothing is moving on the platform
"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 ~60 cars
means they do not come back. 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.
**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, not 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 the "why nothing is moving" panel covered freight facilities only,
so a platform had never had anything to say for itself. It now reports passengers with no train, a
train whose card bars Porters, full coaches, full red slots, the same-district rule, and the coach
shortage itself — naming how many coaches sit in Classification and what brings them back. The reason
text comes from the engine's own predicate, so the panel cannot drift from the rule that 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`; it is named by its Office tier now.
### What differs from the main line
- **The Timetabled discard is the plain rule here, not a setting.** On main it is a New Game checkbox
(`discardTimetabled`, on by default) because Jesse's reasoning is about games run LONGER than five
Days. This line has no settings-form scaffolding and no `?toss=` parameter — a Timetabled train can
simply be discarded. Both lines play the same game at main's default. The house rule itself is kept
in the engine, unreachable from the page, so `keepReason` is identical on both lines and old saves
still replay.
- **The Day-end dialog's goal half is written for this line's Frame, not ported.** Main reads a
COMBINED Revenue floor off its victory dials; this line has no such dials and instead has
`objective`, which already resolves a per-player target from the game length and whether you are on
pace. The dialog asks `objective` rather than recomputing it, so it cannot disagree with the status
block. Its tests were rewritten to match.
- **`Frame` gained a `viewer` field.** `snapshot` has always taken a viewer and resolved `revenue`,
`handCount` and `objective` against it; the index itself was missing, so a panel drawing the whole
table had no way to mark which row is yours without assuming the web client's `viewer = 0`.
- The lobby, the game types and the multiplayer handoff overlay that main's versions of `main.ts`,
`play.html` and `web.test.ts` are built around do not exist here, so those four files were
hand-resolved rather than patched.
**636 tests pass.** No replay was retired: Gitea#9 only ADDS a legal action, #8 only widens one, and
neither #10 nor #2 touches the engine, so nothing already recorded means something different now.
---
## 0.4.9f — 2026-08-22
Three more from the v0.4.9e gameplay-testing round, filed as Gitea issues: **#4** extras did not start
+56 -12
View File
@@ -25,8 +25,43 @@ Queued 2026-08-22, from the v0.4.9e gameplay-testing report filed as Gitea issue
Reasoning in `docs/rules/implications.md` §6.2.
3. ~~**Gitea#7 — coach counts on four train cards**~~ — done in v0.4.9f. 1/2 Crack Limited 3 → 2,
5/6 The Sparrow 2 → 3.
4. **Gitea#2 — four porters, two passengers on the platform, and only one may be worked.**
DIAGNOSED, AWAITING JESSE'S RULING — see Play Balance below.
4. ~~**Gitea#2 — four porters, two passengers on the platform, and only one may be worked**~~ —
RULED AND FIXED in v0.4.9g, though not the way the report implies. The engine is faithful to the
written rules at every step; what bites is that BOTH directions of porter work move coaches
one-way into a Classification Yard that comes back only when the Division Yard is bare of all ~60
cars. **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.
Queued 2026-08-24, from the v0.4.9f gameplay-testing round.
5. ~~**Gitea#8 — the per-diem train could not couple a caboose**~~ — done in v0.4.9g. All six
cabooses are minted `loaded: true` because §2.2's "coloured is loaded, white is empty" doubles as
a PIECE COUNT in the supply table 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. A caboose carries the crew, not freight, so
it is never a load.
6. ~~**Gitea#10 — a dialog when the Day rolls over**~~ — done in v0.4.9g. "Hard to keep track of
time." Nothing on screen was wrong, but a Day turns over inside the phases that run themselves, so
it passes between one click and the next and the phase banner is gone in 2.6 seconds. A modal
stops and waits, and carries the standings, the Days left and your Revenue against the target.
7. ~~**Gitea#9 — a Timetabled train may be discarded**~~ — done in v0.4.9g, and it SUPERSEDES
Gitea#6 (item 2 above), shipped three days earlier in v0.4.9f. 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. **This line takes the plain rule**; on main it is a New Game setting,
because Jesse's reasoning is about games run longer than five Days. Reasoning in
`docs/rules/implications.md` §6.2.
Still open on the tracker and NOT in v0.4.9g — these are rules and content work rather than bugs, and
none has been started on this line: **Gitea#1** (Kick — a new special card and its rules), **Gitea#5**
(the Yard Office is not implemented), **Gitea#11** (allow extended play past the last Day, and update
the summary/win screen), **Gitea#12** (Inspection Cards), **Gitea#13** (updated rules for Extra trains
that have to run full boxcars), **Gitea#14** (update the card counts in the deck).
The rest of v0.4.9's plan (coordinate labels, the no-switching fix, the expedite rewrite, the
`evaluateClearance` bug, the splash artwork) is built; see Done below.
@@ -236,17 +271,26 @@ not read that drop as a deck problem.
halves of every passenger cycle. Traced over the reported game, the coach pool runs from 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
the type-and-state being asked for rather than dry of everything; (b) the same trigger but
return only the cars of that type; (c) leave the rules alone and raise the coach count in
`ROLLING_STOCK_SUPPLY`. Measure (a) or (b) over 400 paired seeds before shipping.
**RULED 2026-08-25 — the shortage stays, and none of the three is being built.** Jesse: "it is
possible to run out, that's part of the strategy." For the record, the options were (a) refill
when the Division Yard is dry of the type-and-state being asked for rather than dry of
everything; (b) the same trigger but return only the cars of that type; (c) leave the rules
alone and raise the coach count in `ROLLING_STOCK_SUPPLY`. All three are declined. What shipped
in v0.4.9g instead is the EXPLANATION — see the item below. 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()`
(`src/sim/narrate.ts`) opens 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
half of Gitea#2 and the half that is unambiguously a bug: the player was not merely blocked,
he was given no reason — the button simply was not there. Worth fixing whichever way the
supply question is settled.
- [x] **A blocked PASSENGER facility produces no impediment at all — FIXED in v0.4.9g.**
`impediments()` (`src/sim/narrate.ts`) opened with `if (!f || f.kind !== 'freight') continue`,
so the "why 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 was unambiguously a bug: the player was not merely
blocked, he was given no reason — the button simply was not there. A platform now reports
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.
Numbers chosen to fix a measured problem rather than taken from the design. Revisit once the victory
target is settled and freight carries its intended share; read no balance conclusion from a revenue
+47 -16
View File
@@ -860,34 +860,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
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."
**SUPERSEDED ONCE. Read both rulings; the second narrows the first.**
**Extras count.** An Extra is a train, even though it runs once and ends in the Salvage Yard where a
Timetabled card joins the timetable for the rest of the game.
**Gitea#6, v0.4.9e playtest:** "Players are not allowed to discard Train cards. They 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 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
exception for any card type, so this is a ruling rather than a gap — the prototype rules do not
address it either way.
exception for any card type, so both of these are rulings rather than gaps — the prototype rules do
not address it either way.
### 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:
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).
A player holding four trains therefore has exactly one legal way to conclude the turn — play one —
without anything in the engine ever computing "you must play a train". The corner cannot lock a
player in, because **playing a train card is unconditionally legal**: `card.play`'s train case
A player holding four undiscardable trains therefore has exactly one legal way to conclude the turn —
play one — without anything in the engine ever computing "you must play a train". The corner cannot
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
hand (it simply schedules nothing). Confirmed by playing it: a hand of four trains offers zero
discards, no `draw.end`, and four plays.
hand (it simply schedules nothing). Confirmed by playing it: such a hand offers zero discards, no
`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
`check`, so the option stops being offered; and the developer bot already reaches for `card.play`
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "station-master",
"version": "0.4.9f",
"version": "0.4.9g",
"private": true,
"type": "module",
"description": "Station Master — a railroad operations game",
+61 -19
View File
@@ -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"
* means, so the rule, the UI's reason text and any test all ask the same question.
* A Timetabled or Extra train card (§6.2) — the one place that decides what "a train card" means.
*/
export function isTrainCard(s: GameState, cardId: CardId): boolean {
const kind = s.cards.get(cardId)?.kind.kind;
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
* 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';
/**
* 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?
*
@@ -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
* reported "no train at the Office", which is both wrong and unhelpful.
*/
function passengerRefusal(
export function passengerRefusal(
s: GameState,
player: PlayerIndex,
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.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;
if (freight > 0 && !freightBudgetLeft(s, player, tray, i.to, freight)) return 'FREIGHT_WORKED_HERE';
}
@@ -882,30 +925,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
* pressure on it — but it never goes onto a Department pile. The consequence is the point of the
* 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.
* `keepReason` holds the rule; this asks it. A Timetabled train is discardable unless the
* `discardTimetabled` house rule is off, and an Extra never is.
*
* Extras count. They are trains — Jesse's ruling in the same breath — even though an Extra runs
* once and ends in the Salvage Yard while a Timetabled card joins the timetable for the rest of
* the game.
* WHERE THE DISCARD GOES IS THE OTHER HALF OF THE RULING. "If someone else wants to pick it up,
* they are more than able to" — a discard goes face-up on a Department pile, which is exactly
* 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
* stops being offered for these cards; the bot needs no separate rule and already reaches for
* `card.play` before it reaches for a discard.
* The corner Gitea#6 created still exists when the setting is off, and is still deliberate: a
* player holding four undiscardable trains has one way forward, which is to PLAY one. `draw.end`
* 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': {
if (!inPhase(s, 'localOps')) return 'WRONG_PHASE';
const hand = s.decks.hands.get(player) ?? [];
if (!hand.includes(i.cardId)) return 'CARD_NOT_IN_HAND';
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;
}
+32 -1
View File
@@ -1031,13 +1031,36 @@ export type RevenueRules = {
*/
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()`. */
export type HouseRuleOverrides = {
startingHand?: StartingHand;
revenue?: Partial<RevenueRules>;
extraStart?: ExtraStartRule;
discardTimetabled?: boolean;
};
/** The dialog's range. Zero is a real setting: it switches an economy off so the others can be read. */
@@ -1051,6 +1074,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
// its testers in the middle of a bugfix release.
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,
};
/**
@@ -1067,6 +1094,9 @@ export const LEGACY_HOUSE_RULES: HouseRules = {
revenue: { passengerPerCoach: 1, freightPerLoad: 1, trainPerTransit: 1 },
// An Extra could always be started at a Control Point in these games, in any district.
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. */
@@ -1086,6 +1116,7 @@ export function houseRules(config: { houseRules?: HouseRuleOverrides }): HouseRu
trainPerTransit: clamp(rev.trainPerTransit, d.revenue.trainPerTransit),
},
extraStart: given.extraStart ?? d.extraStart,
discardTimetabled: given.discardTimetabled ?? d.discardTimetabled,
};
}
+113 -3
View File
@@ -18,7 +18,7 @@
import { MAX_CONSIST } from '../engine/content.ts';
import { adTrackCount, coordKey, seatOf, turnOf } 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';
// ---------------------------------------------------------------------------
@@ -510,14 +510,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
* 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[] {
const out: Impediment[] = [];
const area = areaOf(s, player);
for (const [key, card] of area.grid) {
const f = card.facility;
if (!f || f.kind !== 'freight') continue;
const name = card.geometry.kind === 'facility' ? card.geometry.facility : 'facility';
if (!f) continue;
/**
* 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);
// A load that cannot move, with Laborers standing by, is the worst state a facility reaches:
+30 -7
View File
@@ -18,7 +18,7 @@ import {
laborersLeft,
movesFor,
ownCutFor,
isTrainCard,
keepReason,
portersLeft,
resolveExtraStart,
selectDestination,
@@ -368,6 +368,15 @@ export type Frame = {
* player order once §4.4's D12 decided who sits where.
*/
players: { index: number; seat: number; name: string; revenue: number; hand: number }[];
/**
* WHICH of those players is the one this Frame was drawn for.
*
* `snapshot` has always taken a `viewer`, and everything viewer-scoped on the Frame — `revenue`,
* `handCount`, `objective`, `blocked` — is already resolved against it. What was missing was the
* INDEX itself, so a panel showing the whole table (the Day-end dialog, Gitea#10) had no way to
* mark which row is yours without assuming the web client's `viewer = 0`.
*/
viewer: number;
/** How many cards the VIEWER holds. Other players' counts are in `players`. */
handCount: number;
lines: { text: string; tone: string }[];
@@ -430,12 +439,22 @@ export type Frame = {
/**
* 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
* cards those are, not merely find that a button is missing — that silence is the whole of the
* Gitea#2 complaint, where a blocked platform left the board with nothing to click and no reason.
* Named for the rule rather than for trains, since it answers the question the panel is asking.
* The player has to be told which cards those are, not merely find that a button is missing —
* that silence is the whole of the Gitea#2 complaint, where a blocked platform left the board with
* nothing to click and no reason. Named for the rule rather than for trains, since it answers the
* question the panel is asking.
*/
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;
/** The face-up card on top of each Department pile — the only one that may be drawn. */
departments: string[];
@@ -1232,7 +1251,8 @@ export function snapshot(
*/
hand: [...(s.decks.hands.get(viewer) ?? [])].reverse().map((id) => cardName(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,
departments: s.decks.departments.map((pile) => {
const top = pile[pile.length - 1];
@@ -1270,6 +1290,7 @@ export function snapshot(
revenue: p.revenue,
hand: (s.decks.hands.get(p.index) ?? []).length,
})),
viewer,
handCount: (s.decks.hands.get(viewer) ?? []).length,
objective: objectiveOf(s, viewer),
runningRow: area.runningRow,
@@ -1497,7 +1518,9 @@ export function trainRules(t: {
}
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.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) {
parts.push('STOPS ONCE FOR SPEECHES, then runs expedited from its next Office onward');
}
+56 -13
View File
@@ -9,7 +9,7 @@ import { BOARD_CSS, divisionSvg, officeSvg } from '../sim/board-svg.ts';
import { TURNCHART_CSS, turnChartHtml } from '../sim/turnchart.ts';
import type { Frame } from '../sim/view.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 { playCue } from './sound.ts';
import { EXTRA_START_LABELS, MOVES_PER_LOCAL_OPS, STARTING_HAND_LABELS, houseRules } from '../engine/content.ts';
@@ -69,6 +69,13 @@ let soundOn = false;
* ended or what happened in between. Reported exactly that way.
*/
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.
*
@@ -233,6 +240,34 @@ function applyCapabilities(): void {
hide('newgame', c.newGame);
}
/**
* 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 one transient signal the page
* already had, the phase banner, is gone in 2.6 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();
}
function render(): void {
const f = session.view();
const menu = session.menu();
@@ -559,6 +594,8 @@ function render(): void {
}
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
* out loud rather than left in the log. Drained, so it shows once and does not re-fire on a redraw.
@@ -612,6 +649,9 @@ function renderUndo(): void {
// 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.
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;
};
}
@@ -952,24 +992,27 @@ function renderActions(
/**
* 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
* trains has exactly one way forward — play one onto the timetable. The rule creates that corner
* deliberately and needs no machinery, but it must not be a corner the player has to infer from
* a discard button that has quietly stopped appearing.
* §6.2 (Gitea#9) leaves ONE kind of undiscardable card on this line — an Extra, which never
* joins the timetable and so can never be what jams it. A player holding nothing but Extras has
* exactly one way forward: play one. The rule creates that corner deliberately and 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`) rather than a sentence
* written here, so this cannot drift from the rule that actually refused — and main, where
* `discardTimetabled` is a New Game setting and a Timetabled train is the second kind, reads the
* identical code.
*/
const stuck = f.handDiscardable.length > 0 && f.handDiscardable.every((d) => !d);
const why = f.handKeepWhy.find((w) => w !== null) ?? '';
const tip = stuck
? '§6.2 — you may not end a turn holding more than three cards, and a TRAIN CARD IS NEVER ' +
'DISCARDED. Every card you hold is a train, so the only way on is to play one onto the ' +
'timetable. A train may be held for as many Stages and Days as you like; it just cannot be ' +
'thrown away.'
: '§6.2 — 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 ' +
'discardable and can only be played.';
? 'You may not end a turn holding more than three cards, and every card you hold is one that ' +
`cannot be thrown away. ${why} The only way on is to play one.`
: '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 — where a rival may pick ' +
'it up.';
html +=
`<div class="grp"><button class="act blocked" disabled data-tip="${tip.replace(/"/g, '&quot;')}">` +
(stuck
? `End Local Operations — play a train card first, they cannot be discarded (holding ${hand})`
? `End Local Operations — play an Extra first, they cannot be discarded (holding ${hand})`
: `End Local Operations — play or discard down to three first (holding ${hand})`) +
`</button></div>`;
}
+71 -6
View File
@@ -31,16 +31,17 @@ export function cardRow(name: string, why: string, playable: boolean | null): st
}
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
// that on the train, not deduce it from a button that is not there.
const held = 'You may hold this for as many Stages and Days as you like — but a train card is ' +
'never discarded (§6.2). The only way it leaves your hand is onto the timetable.';
// §6.2 — say so on the card itself. A player who cannot discard a train needs to read that on the
// train, not deduce it from a button that is not there. The sentence comes off the Frame
// (`handKeepWhy`) rather than being written here: since Gitea#9 the answer depends on the card,
// and this line's rule (an Extra never, a Timetabled train always) is not main's, where it also
// depends on a New Game setting. One panel, whichever rule the game is playing.
return f.hand.length
? f.hand
.map((h, i) => {
const what = f.handWhat[i] ?? '';
const keep = f.handDiscardable[i] === false;
return cardRow(h, keep ? [what, held].filter(Boolean).join(' · ') : what, canPlay[i] ?? null);
const held = f.handKeepWhy[i];
return cardRow(h, held ? [what, held].filter(Boolean).join(' · ') : what, canPlay[i] ?? null);
})
.join('')
: '<span class="dim">empty</span>';
@@ -153,6 +154,62 @@ export function timetableHtml(f: Frame, justSet: number | null): string {
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.
*
* THE GOAL COMES OFF `f.objective`, WHICH ALREADY RESOLVES IT. This line scores each player against
* a per-player target read from the game LENGTH (`lengthProfile`), and `objectiveOf` has always
* worked out both that target and whether the viewer is keeping up with the clock. Recomputing
* either here would be a second implementation of the same rule, one dialog away from disagreeing
* with the status block that shows it every turn. (Main computes a COMBINED floor instead, from
* dials this line does not have — same dialog, different question, so this half is written per line
* rather than ported.)
*/
export function dayEndHtml(f: Frame): string {
const ended = f.day - 1;
const left = f.objective.daysLeft;
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 viewer's own Revenue against the viewer's own target, because that is what `objective` is
// scoped to — `f.revenue` and `f.objective` are drawn for the same player. A target of 0 would be
// a game with no goal, and printing "0" there would read as one already met.
const target =
f.objective.target > 0
? `<p>Revenue <b>${f.revenue}</b> against a target of <b>${f.objective.target}</b> — ` +
`${f.objective.onPace ? 'on pace' : 'behind pace'}.</p>`
: '';
const ahead =
left <= 0
? '<p>That was the last Day on the timetable.</p>'
: `<p><b>Day ${f.day} of ${f.objective.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
);
}
export function blockedHtml(f: Frame): string {
return f.blocked.length === 0
? '<li class="dim">nothing blocked</li>'
@@ -407,4 +464,12 @@ ul.blocked{margin:0;padding-left:18px}
.fstat.good{background:rgba(40,140,60,.28)}
.fstat.bad{background:rgba(190,50,50,.38);font-weight:700}
.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}
`;
+13
View File
@@ -348,6 +348,19 @@ ul.blocked li{padding:2px 0}
</form>
</dialog>
<!-- THE DAY ROLLING OVER (Gitea#10). A Day turns inside the automatic phases, so it happens
between one click and the next; the phase banner and the announcement flash both fade before
someone reading the board notices them. A modal stops and waits, which is the whole request:
"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>
<script type="module" src="./web/main.js"></script>
</body>
</html>
+94 -27
View File
@@ -260,19 +260,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');
});
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
* 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."
* Gitea#6's ruling, v0.4.9e playtest, was that NO train card may be discarded. Gitea#9 narrows
* it — Jesse, 2026-08-24: "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."
*
* Extras count too — an Extra is a train, even though it runs once and ends in the Salvage Yard
* where a Timetabled card joins the timetable for the rest of the game.
* So a Timetabled train is discardable, an EXTRA still is not — it never joins the timetable, so
* 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
* existing rules produce together. Nothing discardable plus "you may not end the turn over the
* limit" leaves exactly one legal way on, and playing a train is unconditionally legal.
* Note there is still no FORCING mechanism, and deliberately so: the corner is what the two
* existing rules produce together whenever the setting is off.
*/
const handOf = (s: GameState, kinds: string[]): string[] => {
// Hand-pick cards of the wanted kinds straight out of the catalogue, so the test does not
@@ -290,35 +291,74 @@ describe('Local Operations: drawing (§6.2)', () => {
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();
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
const [timetabled, extra, track] = handOf(s, ['timetabledTrain', 'extraTrain', 'track']);
assert.equal(
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(
check(s, 0, { type: 'card.discard', cardId: extra!, toSlot: 0 }),
'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);
});
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();
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
const [timetabled] = handOf(s, ['timetabledTrain', 'track']);
const offered = legalActions(s, 0).filter(
(i) => i.type === 'card.discard' && i.cardId === timetabled,
);
assert.deepEqual(offered, [], 'a train discard was offered as a legal action');
assert.ok(applyIntent(s, 0, { type: 'card.discard', cardId: timetabled!, toSlot: 1 }).ok);
const pile = s.decks.departments[1]!;
assert.equal(pile[pile.length - 1], timetabled, 'the train is not face-up on the pile');
});
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();
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']);
assert.ok(four.length > HAND_LIMIT, 'this test needs a hand over the limit');
@@ -335,11 +375,26 @@ describe('Local Operations: drawing (§6.2)', () => {
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', () => {
// "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 [timetabled] = handOf(s, ['timetabledTrain', 'track']);
const [extra] = handOf(s, ['extraTrain', 'track']);
const startDay = s.clock.day;
// Play out Stages by taking whatever ends the current turn, until the Day turns over.
@@ -355,24 +410,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.decks.hands.get(0) ?? []).includes(timetabled!),
(s.decks.hands.get(0) ?? []).includes(extra!),
'the train did not survive being held into the next Day',
);
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',
'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
// reason given.
// reason given. Since Gitea#9 there are TWO reasons, so the card has to say which.
const s = game();
handOf(s, ['timetabledTrain', 'extraTrain', 'track']);
const f = snapshot(s, [], null);
// `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
View File
@@ -10,8 +10,10 @@ import assert from 'node:assert/strict';
import { pump } from '../src/engine/advance.ts';
import type { GameEvent } from '../src/engine/events.ts';
import { areaOf } from '../src/engine/apply.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 { impediments, isVisible, narrate, phaseLabel } from '../src/sim/narrate.ts';
import { compress, rehydrateCells, record, renderHtml } from '../src/sim/replay.ts';
@@ -133,6 +135,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', () => {
const rec = record(1234, 'standard');
+19
View File
@@ -71,6 +71,8 @@ const west = (s: GameState, n = 2): GridCoord => {
};
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 });
// ---------------------------------------------------------------------------
@@ -138,6 +140,23 @@ describe('§7 — what a train may couple', () => {
'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)', () => {
+85 -1
View File
@@ -22,8 +22,10 @@ import { cardDescription, cardName, describeIntent, variantLabel } from '../src/
import { variantsFor } from '../src/engine/track.ts';
import { divisionSvg, officeSvg } from '../src/sim/board-svg.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 { record, renderHtml } from '../src/sim/replay.ts';
import type { Frame } from '../src/sim/view.ts';
import type { GameLength } from '../src/engine/content.ts';
import { snapshot } from '../src/sim/view.ts';
import { createGame as createEngineGame } from '../src/engine/setup.ts';
import {
@@ -2682,6 +2684,88 @@ 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 is gone in 2.6 seconds.
//
// The goal half is written against `f.objective`, which on this line resolves a PER-PLAYER target
// from the game length: `standard` is 20 Revenue over 5 Days. (Main asks a different question of
// the same dialog — a combined floor off the victory dials — so that half is not a shared port.)
const frameAt = (day: number, length: GameLength = 'standard'): Frame => {
const s = createEngineGame({
id: 'dayend',
seed: 4021,
config: {
mode: 'solitaire',
victory: 'firstToTarget',
length,
optionalRules: { reducedVisibility: false, sisterTrains: 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('reads the goal and the pace off the objective rather than working them out again', () => {
// One implementation of "what is the target and am I keeping up" — `objectiveOf`. A dialog that
// recomputed either would be one release away from disagreeing with the status block that shows
// the same numbers every turn.
const f = frameAt(3);
const html = dayEndHtml(f);
assert.ok(html.includes(`<b>${f.objective.target}</b>`), `the target is not shown:\n${html}`);
assert.ok(html.includes(`<b>${f.revenue}</b>`), "the viewer's Revenue is not shown");
// Nothing earned by Day 3 of 5 is behind a 20-Revenue pace, and the dialog says so in words.
assert.equal(f.objective.onPace, false, 'the fixture is no longer behind pace');
assert.ok(html.includes('behind pace'), 'the pace is not reported');
// A campaign game is a different target, and the dialog must not have 20 baked into it.
const long = frameAt(3, 'campaign');
assert.ok(dayEndHtml(long).includes('<b>45</b>'), 'the target does not follow the game length');
assert.ok(dayEndHtml(long).includes('Day 3 of 10'), 'the Day count does not follow the length');
});
it('puts the leader at the top and marks which row is yours', () => {
// The question at the end of a Day is who is ahead, which is not the order the seats are in.
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.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');
// Marked from `f.viewer`, not from an assumed seat 0 — so moving the viewer moves the mark.
f.viewer = 1;
const moved = dayEndHtml(f);
assert.ok(moved.indexOf('(you)') < moved.indexOf('Ada'), 'the mark did not follow the viewer');
});
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)', () => {
it('does not gitignore a source page', () => {
// REGRESSION. `.gitignore` carried `replay*.html` to catch the throwaway files generated at the