diff --git a/CHANGELOG.md b/CHANGELOG.md index 39291d0..f698536 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,145 @@ page as `v0.1.0 · · `, 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 The multiplayer set-up, the lobby, the start of a game, and four things a remote client had never diff --git a/TODO.md b/TODO.md index cab1f9d..685b4a3 100644 --- a/TODO.md +++ b/TODO.md @@ -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 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.** - DIAGNOSED, AWAITING JESSE'S RULING — see Play Balance below. The engine is faithful to the - written rules at every step; what bites is that BOTH directions of porter work move coaches - one-way into a Classification Yard that comes back only when the Division Yard is bare of all - ~60 cars. Sixteen coaches in the game, and the reported save runs dry on Day 5 with eight of - them stranded in Classification. +12b. ~~**Gitea#2 — four porters, two passengers on the platform, and only one may be worked**~~ — + RULED AND FIXED in the release below, though not the way the report implies. The engine is + faithful to the written rules at every step; what bites is that BOTH directions of porter work + move coaches one-way into a Classification Yard that comes back only when the Division Yard is + bare of all ~60 cars. Sixteen coaches in the game, and the reported save runs dry on Day 5 with + eight of them stranded in Classification. **Jesse's ruling: the shortage stays** — "it is + possible to run out, that's part of the strategy" — so the three balance options in Play Balance + below are declined, not deferred. What was actually wrong is that the game said NOTHING: a Porter + action that cannot be taken is simply absent from the menu, and the "why is nothing moving?" + panel covered freight facilities only. That half is fixed. + +12e. ~~**Gitea#8 — the per-diem train could not couple a caboose**~~ — done in the release below. + `ROLLING_STOCK_SUPPLY` mints all six cabooses `loaded: true` because §2.2's "coloured is loaded, + white is empty" doubles as a PIECE COUNT there and there is no white caboose. X22 Pee-Dee, whose + whole card is "may only pick up MTs", read that literally and refused every caboose including the + one it was made up with — set it out and the train was stranded, which made it unplayable rather + than merely restricted. A caboose carries the crew, not freight, so it is never a load. + +12g. ~~**Gitea#9 — a Timetabled train may be discarded**~~ — done in the release below, and it + SUPERSEDES Gitea#6 (item 12c above), shipped three days earlier. A Timetabled train may be tossed + face-up to a Department slot, where a rival may pick it up — which needed no machinery, since + that is where every discard already goes. An Extra still may not: it never joins the timetable, + so it can never be what jams it. On this line the Timetabled half is a New Game setting + (`discardTimetabled`, on by default), because Jesse's reasoning is about games run longer than + five Days; the 0.4.9 line takes the plain rule. Reasoning in `docs/rules/implications.md` §6.2. + +12f. ~~**Gitea#10 — a dialog when the Day rolls over**~~ — done in the release below. "Hard to keep + track of time." Nothing on screen was wrong — the clock, the turn chart and the timetable all + said which Day it was — but a Day turns over inside the phases that run themselves, so it passes + between one click and the next, and the two transient signals the page had (the phase banner at + 2.6s, the announcement flash at 4.2s) are both gone before a player reading the board notices. + A modal stops and waits, and carries the standings, the Days left and the combined target. + Suppressed on the first frame, on Undo stepping back across a rollover, and on the Day the game + ends — the outcome panel is the thing to read then. 13. **Show me the other players' moves, bots included** — raised by Jesse 2026-08-22 from playing a 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 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 #` 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 @@ -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 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 - the type-and-state being asked for rather than dry of everything — the reading a player - rummaging a table-top pile actually uses, and the biggest balance change; (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`, which the item below already sanctions — lowest risk, but it delays the - wall rather than removing it. Measure (a) or (b) over 400 paired seeds before shipping. + **RULED — 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 instead is the + 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()` - (`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 above 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.** `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 above 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. - [ ] **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, diff --git a/docs/rules/implications.md b/docs/rules/implications.md index 06b2021..be10170 100644 --- a/docs/rules/implications.md +++ b/docs/rules/implications.md @@ -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 -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` diff --git a/package.json b/package.json index 134a1cf..7fbf62c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "station-master", - "version": "0.7.0", + "version": "0.7.1", "private": true, "type": "module", "description": "Station Master — a railroad operations game", diff --git a/src/engine/apply.ts b/src/engine/apply.ts index a8028a2..5362525 100644 --- a/src/engine/apply.ts +++ b/src/engine/apply.ts @@ -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'; } @@ -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 - * 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; } diff --git a/src/engine/content.ts b/src/engine/content.ts index 74ba2fd..576b9ef 100644 --- a/src/engine/content.ts +++ b/src/engine/content.ts @@ -1058,13 +1058,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; extraStart?: ExtraStartRule; + discardTimetabled?: boolean; }; /** 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 // 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, }; /** @@ -1094,6 +1121,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. */ @@ -1113,6 +1143,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, }; } diff --git a/src/sim/narrate.ts b/src/sim/narrate.ts index 1369230..cbb0974 100644 --- a/src/sim/narrate.ts +++ b/src/sim/narrate.ts @@ -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'; // --------------------------------------------------------------------------- @@ -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 * 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: diff --git a/src/sim/view.ts b/src/sim/view.ts index 79a1cc9..ebcff80 100644 --- a/src/sim/view.ts +++ b/src/sim/view.ts @@ -18,7 +18,7 @@ import { laborersLeft, movesFor, ownCutFor, - isTrainCard, + keepReason, portersLeft, resolveExtraStart, selectDestination, @@ -492,12 +492,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[]; @@ -1294,7 +1304,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]; @@ -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.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'); } diff --git a/src/web/main.ts b/src/web/main.ts index df42a9c..5306f2a 100644 --- a/src/web/main.ts +++ b/src/web/main.ts @@ -10,7 +10,7 @@ import { TURNCHART_CSS, turnChartHtml } from '../sim/turnchart.ts'; import type { Frame } from '../sim/view.ts'; import { seatLabel } 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 { @@ -158,6 +158,13 @@ let zoom = settings.zoom; * 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. * @@ -302,6 +309,10 @@ function gameOptionsFromUrl(params: URLSearchParams): NewGameOptions { if (STARTING_HAND_LABELS.some((o) => o.value === hand)) rules.startingHand = hand as StartingHand; const extra = params.get('extra'); 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 = {}; for (const [param, key] of Object.entries(RULE_PARAMS)) { @@ -358,6 +369,7 @@ function solitaireDefaults(options: NewGameOptions): NewGameOptions { houseRules: { startingHand: options.houseRules?.startingHand ?? p.startingHand, extraStart: options.houseRules?.extraStart ?? p.extraStart, + discardTimetabled: options.houseRules?.discardTimetabled ?? p.discardTimetabled, revenue: { passengerPerCoach: revenue.passengerPerCoach ?? p.passengerPerCoach, 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 // here is a setting the dialog silently discards. 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(VICTORY_PARAMS)) { const value = options[key]; @@ -561,6 +576,34 @@ function flashAnnounce(text: string): void { }, 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. * @@ -1186,6 +1229,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. @@ -1236,6 +1281,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; }; } @@ -1577,20 +1625,21 @@ 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 two kinds of undiscardable card — an Extra always, and a Timetabled + * train in a game whose `discardTimetabled` rule is off. Either way a player holding nothing but + * those 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`), 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 why = f.handKeepWhy.find((w) => w !== null) ?? ''; const tip = stuck - ? '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.' + ? '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. A train card is never ' + - 'discardable and can only be played.'; + 'one onto the board, or discard one face-up to a Department slot — where a rival may pick ' + + 'it up.'; html += `
+
+ + +
@@ -850,6 +857,13 @@ ul.blocked li{padding:2px 0} +
+ + +
@@ -863,6 +877,19 @@ ul.blocked li{padding:2px 0} + + +
+
+ + + +
+
+