diff --git a/CHANGELOG.md b/CHANGELOG.md index 459ce76..3be2365 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,107 @@ page as `v0.1.0 · · `, 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 diff --git a/TODO.md b/TODO.md index b25c917..e1735ea 100644 --- a/TODO.md +++ b/TODO.md @@ -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 diff --git a/docs/rules/implications.md b/docs/rules/implications.md index 605e070..89cc4a7 100644 --- a/docs/rules/implications.md +++ b/docs/rules/implications.md @@ -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` diff --git a/package.json b/package.json index a591c13..7999fc4 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/engine/apply.ts b/src/engine/apply.ts index 3a9745b..f48d579 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'; } @@ -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; } diff --git a/src/engine/content.ts b/src/engine/content.ts index 3d95308..4ac3a6a 100644 --- a/src/engine/content.ts +++ b/src/engine/content.ts @@ -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; 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, }; } diff --git a/src/sim/narrate.ts b/src/sim/narrate.ts index fa382dd..ab3050c 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'; // --------------------------------------------------------------------------- @@ -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: diff --git a/src/sim/view.ts b/src/sim/view.ts index 3acdf03..a0adbd2 100644 --- a/src/sim/view.ts +++ b/src/sim/view.ts @@ -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'); } diff --git a/src/web/main.ts b/src/web/main.ts index 271e25e..7656277 100644 --- a/src/web/main.ts +++ b/src/web/main.ts @@ -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 += `
`; } diff --git a/src/web/panels.ts b/src/web/panels.ts index d0db14a..664c0f7 100644 --- a/src/web/panels.ts +++ b/src/web/panels.ts @@ -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('') : 'empty'; @@ -153,6 +154,62 @@ export function timetableHtml(f: Frame, justSet: number | null): string { return `
${slots}
`; } +/** + * 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) => + `${esc(p.name)}` + + `${p.index === f.viewer ? ' (you)' : ''}` + + `${p.revenue}`, + ) + .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 + ? `

Revenue ${f.revenue} against a target of ${f.objective.target} — ` + + `${f.objective.onPace ? 'on pace' : 'behind pace'}.

` + : ''; + + const ahead = + left <= 0 + ? '

That was the last Day on the timetable.

' + : `

Day ${f.day} of ${f.objective.days} begins now — ${left} ${left === 1 ? 'Day' : 'Days'} left to run.

`; + + return ( + `

Day ${ended} has ended

` + + ahead + + `${standings}
` + + target + ); +} + export function blockedHtml(f: Frame): string { return f.blocked.length === 0 ? '
  • nothing blocked
  • ' @@ -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} `; diff --git a/src/web/play.html b/src/web/play.html index b67563c..bd6ea12 100644 --- a/src/web/play.html +++ b/src/web/play.html @@ -348,6 +348,19 @@ ul.blocked li{padding:2px 0} + + +
    +
    + + + +
    +
    + diff --git a/test/apply.test.ts b/test/apply.test.ts index edc24d1..ca49598 100644 --- a/test/apply.test.ts +++ b/test/apply.test.ts @@ -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', + ); }); }); diff --git a/test/replay.test.ts b/test/replay.test.ts index f5e36a0..0bccb64 100644 --- a/test/replay.test.ts +++ b/test/replay.test.ts @@ -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'); diff --git a/test/train-rules.test.ts b/test/train-rules.test.ts index 2d547b9..ae24a44 100644 --- a/test/train-rules.test.ts +++ b/test/train-rules.test.ts @@ -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)', () => { diff --git a/test/web.test.ts b/test/web.test.ts index 5c9af70..d81f6de 100644 --- a/test/web.test.ts +++ b/test/web.test.ts @@ -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(`${f.objective.target}`), `the target is not shown:\n${html}`); + assert.ok(html.includes(`${f.revenue}`), "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('45'), '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