diff --git a/CHANGELOG.md b/CHANGELOG.md index e27f624..145040b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,80 @@ page as `v0.1.0 · · `, so what is deployed can always be identifie --- +## 0.7.4 — 2026-08-29 + +Three rules issues off the tracker, in the order Jesse asked for them: #13, #5, #19. All three are +rules Jesse has designed or redefined, and two of them turned out to be rules the code claimed to +have and did not. + +### Some Extras must run loaded (Gitea#13) + +X17 Campaign, X18 Circus and X19 Military now prefer loaded cars at make-up — "if not loaded, then +empty, and if none available, run without". It is a preference ORDER, so the rule is asked of the +Division Yard: an empty is refused only while the yard can still supply a loaded car this train +would take, per category, and once it cannot the empty is legal and the train may depart short. + +The per-stop point is **once per Office Area** rather than once per game (Jesse: "in a multiplayer +game, each player could score if the circus stops in their area"), and only when the train is fully +loaded — every non-caboose car, with a coach counting as loaded when occupied. That last detail is +what makes the rule work for the Campaign Train, which carries one coach and no freight, so "fully +loaded" is exactly "the candidate is aboard". X17 also GAINS the point; it had `stopThenExpedite` +and no scoring rule at all. + +**Two bugs found doing it.** `ConsistSpec.emptiesOnly` was declared on X13 Appleseed, rendered to +the player as "(empties only)", and enforced nowhere — the same rule as this issue pointing the +other way, so it would have been perverse to add one and leave the other. And a set-up out on the +Mainline paid its point to PLAYER 0 whoever was playing, because `playerAtSeat` needs a seat and off +the grid the fallback was `0`; scoping the rule to Office Areas removes that rather than patching it. + +### The Yard Office is offered, reachable, and can be run into (Gitea#5) + +It was implemented, in a form missing all three of the rule's conditions: a qualifying train was +TELEPORTED onto the card. Nobody was asked, no route was computed — so the card's own printed "that +can reach the yard office in one move" was unenforced — and because nothing was walked, nothing was +ever met on the way in. + +Now it is offered to the district's owner; reachability is the engine's own move walk, which already +means what the card means; and cars on the lead collide. The walk COUPLES standing cars rather than +treating them as obstacles, because that is what a switching move does — so what an arriving train +would have coupled is what it is about to hit, and `destination.couples` turned out to be the +fouling signal with no new machinery. Per Jesse's ruling the two failures are kept apart: no route +means no offer and the history says why; a route that exists but is fouled is offered, and taking it +crashes. + +**This needed a refactor that #19 then reused.** `pendingDecision` was one question asked of one +player, and `currentActor` hardcoded that. It is a discriminated union now, with `decisionActor` as +the single place mapping a question to whoever answers it; six copies of `pendingDecision !== null ? +superintendent : currentActor` across engine, sim, web and tests collapse into `actingPlayer`. + +### Red Flags, redesigned (Gitea#19) + +The old card protected a stopped train out on the Mainline: offered 4,212 times and played 4 across +600 games. It is replaced outright by a flag planted on one side of your own Limits, holding the +next train from that direction — "Flag East holds westbound trains" — spent on the train it stops. +One card, one train, so there is no lifting action to build and a flag cannot strangle the Division. + +It can also be played **out of phase**: when an arrival would certainly collide and the district's +owner holds the card, the phase breaks in with the question. Offered only to somebody holding one, +because a prompt with a single button is not a choice and would leak that a collision is coming. + +**The bot still never plays it, and the reason changed — measured, not assumed.** It now takes the +out-of-phase prompt unconditionally, since the engine has already established the danger. Over 200 +solitaire games it plays ZERO, because the prompt needs an arrival that would collide (about one +game in seven) to coincide with holding the card from a three-card hand out of 121. The anomaly +exemption in `sim.test.ts` stays, but its comment no longer claims the bot is unwilling; what is +left to fix is the half of the card a human uses — planting a flag on purpose to buy switching time. + +**A bug the redesign walked into**, recorded because the next interruption will meet it too: the +flag was originally taken down in a `reduce` case, which never fires for an event `advance.ts` +emits — the phase driver mutates and then describes. The flag stayed up and held every train that +came. `test/events.test.ts`'s unreduced-event registry is what makes that class of mistake visible, +and `redFlagSpent` is now on it deliberately. + +858 tests pass. + +--- + ## 0.7.3 — 2026-08-29 Two issues off the tracker, and they are halves of one thing: the end of a game. Gitea#11 stops the diff --git a/README.md b/README.md index 388fd51..a1507d7 100644 --- a/README.md +++ b/README.md @@ -132,6 +132,13 @@ is the thing this machinery exists to prevent. roughly a third of the event types are never reduced at all. Anything that needs to rebuild a game replays the intents. - **Never call `Math.random()`.** One ambient random call silently breaks replay. +- **The Mainline Phase can stop and ask, and there are three questions it asks.** §8.1's clearance + ruling goes to the Superintendent; the Yard Office offer and the Red Flag prompt go to the owner of + the district a train is arriving at. `pendingDecision` is a discriminated union and `decisionActor` + is the single place that maps a question to whoever must answer it — a new question adds a case + there and nowhere else. **Ask before the move is committed:** returning `needsClearance` unwinds + the whole phase and the driver re-enters from the top, so anything already mutated is applied + twice or left half-done. - **A game ends by PAUSING, and the first ending is the real one.** Running out of Days, or closing short of the combined Revenue floor, puts the game in `awaitingExtension` rather than `finished`: the table is asked whether to play one more Day, unanimously, and asked again at the end of every diff --git a/TODO.md b/TODO.md index 360f972..c45f470 100644 --- a/TODO.md +++ b/TODO.md @@ -414,21 +414,23 @@ The replay viewer, the save format, and how a game gets shared. ## Bot Performance -- [ ] **THE BOT WILL NOT PLAY RED FLAGS, and the reachability canary now says so.** Measured over - 600 games (2026-08-26): `maneuver.redFlags` is OFFERED **4,212 times**, first in game 5 — the - rule is live and constantly available — and the bot PLAYS it **4 times**, first in game 252. - It is exempted by name in `sim.test.ts`'s anomaly test alongside `flyingSwitch`, and deleting - that line is what proves this fixed. +- [ ] **THE BOT NEVER PLAYS RED FLAGS — and since Gitea#19 that is deck luck, not unwillingness.** + **Re-measured 2026-08-29, after the card was redesigned: `redFlagsSet` fires ZERO times in 200 + solitaire games.** The old measurement (600 games: OFFERED 4,212 times, PLAYED 4) described a + bot that declined a card it was constantly handed. That bot is gone. - Two changes compounded to make it rarer without breaking anything: Gitea#14 took Red Flags from - 5 copies to the sheet's 3, and Gitea#3 shortened most crossings to a single Stage, so the - window in which a train is STANDING on a Mainline card — the only place the card may be played - — is now usually one Stage wide. + Gitea#19 replaced the rule outright: a flag is planted on one side of your own Limits and holds + the next train from that direction, and it can be played out of phase when the engine breaks in + with "COLLISION RISK! FLAG AGAINST T2?". The bot takes that prompt **unconditionally** — the + engine only raises it when an arrival is certainly about to collide, so there is nothing left + to judge. It still never plays one, because the prompt needs two things to coincide: an arrival + that would collide (0.14 collisions per game, about one game in seven) AND the district's owner + holding a Red Flags card at that moment, from a three-card hand drawn out of 121. - -What the developer bot can and cannot yet do, measured. Every revenue figure below measured before -v0.4.7 is low by roughly half a point — see the stub-industry entry — and the rebalance pass should -not read that drop as a deck problem. + **What is left to fix is the OTHER half of the card**, and it is the half a human would use: + planting a flag on purpose to buy a Stage for switching. That needs the bot to know it wants + time, which it has no notion of today. Until then the anomaly canary in `sim.test.ts` is + measuring deck luck rather than reachability, and its comment now says so. - [ ] **THE BOT DOES NOT KNOW TO BRING AN EXPEDITED TRAIN BACK TO THE STATION — new in v0.4.9.** The `expediteFault` mechanic (§7, Q3) charges 1 Revenue every Mainline Phase an expedited train diff --git a/package.json b/package.json index 1a7644e..95b3eb3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "station-master", - "version": "0.7.3", + "version": "0.7.4", "private": true, "type": "module", "description": "Station Master — a railroad operations game", diff --git a/src/engine/advance.ts b/src/engine/advance.ts index 93b9922..2c44f06 100644 --- a/src/engine/advance.ts +++ b/src/engine/advance.ts @@ -974,13 +974,22 @@ function moveTrain(s: GameState, id: TrayId, tray: CrewTray, events: GameEvent[] * half-applied. Asking after the `transits` filter below cost the train its place on the card * and it was never seen again — the question was asked and the answer had nowhere to land. */ - if (dest?.kind === 'office' && yardOfficeQuestion(s, id, tray, dest.seat, events) === 'ask') { - return 'needsClearance'; + if (dest?.kind === 'office') { + /** + * §Q, RED FLAGS (Gitea#19) — asked and answered before the train leaves the card, for exactly + * the reason the Yard Office offer is (see below): `needsClearance` unwinds the phase. + * + * Order matters. A flag stops the train OUTSIDE the Limits, so it never reaches the point + * where the Yard Office would be offered — flagging is about keeping a train out altogether. + */ + const flagged = redFlagStop(s, id, tray, dest, events); + if (flagged === 'ask') return 'needsClearance'; + if (flagged === 'held') return 'held'; + + if (yardOfficeQuestion(s, id, tray, dest.seat, events) === 'ask') return 'needsClearance'; } node.transits = node.transits.filter((t) => t.tray !== id); - // Red Flags protect a train while it is stopped here; once it rolls, the flags come in. - if (node.redFlagged) node.redFlagged = node.redFlagged.filter((t) => t !== id); if (!dest) return 'held'; @@ -1087,10 +1096,10 @@ function evaluateClearance( // from moving". Flagging is per-train rather than per-card, so it protects one specific train // where ABS Signals protects everything on the card. // - // Both of these read the card the OTHER train is standing on rather than the card being - // entered. They were the same card while this only looked one card ahead; across a Subdivision - // they are not, and the protection belongs where the train it protects actually is. - if (onNode?.kind === 'mainline' && (onNode.redFlagged ?? []).includes(other)) return 'blocked'; + // Red Flags used to protect a stopped train here as well. Gitea#19 replaced that rule outright + // (Jesse, 2026-08-29): a flag is now planted on a district's Limits and holds trains coming from + // one direction, so it never applies out on the Mainline. ABS Signals is what protects a train + // standing on a Mainline card now, and it always did the job better. /** * ABS Signals — "this is played on a mainline card to prevent collisions. If a collision would @@ -1147,6 +1156,103 @@ function evaluateClearance( * way the train is already facing. Reversing is a separate Move (§A.5), so a Yard Office that can * only be reached by backing up is correctly "not in one move". */ +/** + * The flag comes down as it stops the train — one card, one train (Gitea#19). + * + * MUTATES RATHER THAN EMITTING A REDUCED EVENT, because this is the phase driver: `advance.ts` + * changes state directly and then describes what it did, and roughly a third of the event types are + * never reduced at all (`README.md`, and `tally.ts` on the same asymmetry). A `redFlagSpent` + * reducer case looked right and never fired — the flag stayed up and held every train that came. + */ +function spendFlag( + office: Extract, + tray: CrewTray, + side: Direction, + events: GameEvent[], +): 'held' { + delete office.redFlag; + events.push({ type: 'redFlagSpent', seat: office.seat, side, trainNumber: tray.trainNumber ?? 0 }); + events.push({ + type: 'trainHeld', + trainNumber: tray.trainNumber ?? 0, + reason: 'Red Flags — held short of the Limits', + }); + return 'held'; +} + +/** + * §Q, RED FLAGS (Gitea#19) — does a flag stop this train, and should its owner be offered one? + * + * Two jobs, because they are the same moment seen twice: a flag already planted stops the train + * outright, and a train about to run into trouble is the cue to offer a flag to somebody holding + * the card. "You can play the card normally or out of phase, but only if you need it." + * + * - `held` — a flag was up on the side this train is coming from. It loses this Mainline + * Phase and the flag comes down with it: one card, one train (Jesse, 2026-08-29). + * - `ask` — entering would collide and the district's owner holds a Red Flags card. + * - `proceed` — neither. + * + * WHICH SIDE. A train running WEST arrives from the east, so `FLAG EAST` is what holds it — which + * is the example the issue gives, and the reason the flag names a side rather than a heading. + */ +function redFlagStop( + s: GameState, + id: TrayId, + tray: CrewTray, + dest: Extract, + events: GameEvent[], +): 'held' | 'ask' | 'proceed' { + const from: Direction = tray.direction === 'east' ? 'west' : 'east'; + + // The answer to a prompt already put. Consumed here so the driver cannot ask twice. + const answer = s.clock.decisionAnswer; + if (answer && answer.kind === 'redFlag' && answer.train === id) { + s.clock.decisionAnswer = null; + if (!answer.flag) return 'proceed'; + // The card was spent planting the flag; it stops this train and comes down again at once. + return spendFlag(dest, tray, from, events); + } + + if (dest.redFlag === from) return spendFlag(dest, tray, from, events); + + /** + * "In actual cases of danger… if there is a train or cars on the track and there will be a + * collision, then you break in with a dialog." The two ways an arrival collides are §8.3's own: + * no free A/D track, and cars fouling the Running Track. Asked only of a player who can actually + * answer — offering a flag to somebody holding no card is a prompt with one button. + */ + const owner = playerAtSeat(s, dest.seat); + const holdsFlag = (s.decks.hands.get(owner) ?? []).some((cid) => { + const c = s.cards.get(cid); + return c?.kind.kind === 'maneuver' && c.kind.key === 'redFlags'; + }); + if (!holdsFlag) return 'proceed'; + if (!arrivalWouldCollide(s, id, tray, dest.seat)) return 'proceed'; + + s.clock.pendingDecision = { kind: 'redFlag', train: id, seat: dest.seat, from }; + return 'ask'; +} + +/** + * Would this arrival collide? §8.3's two triggers, asked before the train commits. + * + * Deliberately a READ of the same conditions `arriveAtOffice` enforces rather than a second rule: + * if these two ever diverge, the prompt offers a flag against a collision that will not happen, or + * stays silent before one that will. + */ +function arrivalWouldCollide(s: GameState, id: TrayId, tray: CrewTray, seat: SeatIndex): boolean { + const area = areaAtSeat(s, seat); + const hasInterlocking = [...area.grid.values()].some((c) => c.enhancements.includes('interlocking')); + const full = area.adOccupancy.length >= officeProfile(area.tier).adTracks; + // Interlocking turns a full Office into a hold rather than a collision, so it is not danger. + if (full && !hasInterlocking) return true; + + // A coach may legally stand at the Office while its engine switches (§A.4's carve-out), so it is + // not a hazard to the next arrival. Anything else on the Running Track is. + const officeCard = area.grid.get(coordKey(area.officeCoord)); + return officeCard !== undefined && officeCard.standing.some((c) => c.type !== 'coach'); +} + /** * §11 (Gitea#5) — should the district's owner be asked about the Yard Office, and is there * anything to ask about? @@ -1396,7 +1502,6 @@ function collide( if (n.kind !== 'mainline') continue; n.transits = n.transits.filter((t) => t.tray !== id); if (n.holding) n.holding = n.holding.filter((t) => t !== id); - if (n.redFlagged) n.redFlagged = n.redFlagged.filter((t) => t !== id); } /** * AND OFF THE A/D TRACK, for exactly the same reason as the transit above. diff --git a/src/engine/apply.ts b/src/engine/apply.ts index 91096ed..99d45e7 100644 --- a/src/engine/apply.ts +++ b/src/engine/apply.ts @@ -59,6 +59,7 @@ import { coordKey, cutTowards, decisionActor, + officeNodeFor, isOperationalRail, playerAtSeat, pooled, @@ -803,6 +804,25 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo return null; } + /** + * §Q (Gitea#19) — the Red Flag prompt, the third interruption of the Mainline Phase. + * + * Only ever raised for a player who holds the card, so `flag: true` can always be paid for; the + * card is checked again here because `check` is the authority and a hand can change between the + * prompt being raised and answered. + */ + if (i.type === 'mainline.redFlag') { + if (s.clock.pendingDecision?.kind !== 'redFlag') return 'NO_RED_FLAG_PROMPT'; + if (decisionActor(s) !== player) return 'NOT_YOUR_TURN'; + if (!i.flag) return null; + const held = (s.decks.hands.get(player) ?? []).find((id) => { + const c = s.cards.get(id); + return c?.kind.kind === 'maneuver' && c.kind.key === 'redFlags'; + }); + if (!held) return 'NO_SUCH_CARD'; + return null; + } + /** * §11 (Gitea#5) — the Yard Office offer, the second interruption of the Mainline Phase. * @@ -1024,14 +1044,9 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo const card = s.cards.get(i.cardId); if (!card || !(s.decks.hands.get(player) ?? []).includes(i.cardId)) return 'NO_SUCH_CARD'; if (card.kind.kind !== 'maneuver' || card.kind.key !== 'redFlags') return 'WRONG_INTENT'; - const tray = s.trays.get(i.trayId); - if (!tray) return 'NO_SUCH_TRAY'; - // "A STOPPED train is prevented from being hit" — it protects a train that is standing on a - // Mainline card, which is the only place a rear-ender can happen. - if (tray.position.at !== 'mainline') return 'NO_PLACEMENT'; - const node = s.division.nodes[tray.position.index]; - if (!node || node.kind !== 'mainline') return 'NO_PLACEMENT'; - if ((node.redFlagged ?? []).includes(i.trayId)) return 'OPTION_ALREADY_CHOSEN'; + // §Q (Gitea#19) — a flag goes on your OWN Limits. There is no target train to name and no + // placement to find: the district is yours, and the only question is which side. + if (officeNodeFor(s, seatOf(s, player))?.redFlag === i.side) return 'ALREADY_FLAGGED'; return null; } @@ -1828,10 +1843,25 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] { ]; } - case 'maneuver.redFlags': { - const tray = s.trays.get(i.trayId)!; - const index = tray.position.at === 'mainline' ? tray.position.index : -1; - return [{ type: 'redFlagsSet', player, cardId: i.cardId, trayId: i.trayId, node: index }]; + case 'maneuver.redFlags': + return [{ type: 'redFlagsSet', player, cardId: i.cardId, seat: seatOf(s, player), side: i.side }]; + + case 'mainline.redFlag': { + const pending = s.clock.pendingDecision; + const trainId = pending?.kind === 'redFlag' ? pending.train : ''; + const seat = pending?.kind === 'redFlag' ? pending.seat : 0; + const side = pending?.kind === 'redFlag' ? pending.from : 'east'; + if (!i.flag) return [{ type: 'redFlagRuled', player, trainId, flag: false }]; + const cardId = + i.cardId ?? + (s.decks.hands.get(player) ?? []).find((id) => { + const c = s.cards.get(id); + return c?.kind.kind === 'maneuver' && c.kind.key === 'redFlags'; + })!; + return [ + { type: 'redFlagsSet', player, cardId, seat, side }, + { type: 'redFlagRuled', player, trainId, flag: true }, + ]; } case 'maneuver.flyingSwitch': { @@ -2260,14 +2290,23 @@ export function reduce(s: GameState, e: GameEvent): void { } case 'redFlagsSet': { - const node = s.division.nodes[e.node]; - if (node?.kind === 'mainline') { - node.redFlagged = [...(node.redFlagged ?? []), e.trayId]; - } + const node = officeNodeFor(s, e.seat); + if (node) node.redFlag = e.side; spendCard(s, e.player, e.cardId); break; } + /** + * §Q (Gitea#19) — `redFlagSpent` is NOT reduced, deliberately. It is emitted only by the phase + * driver, which mutates state itself and then describes it (`advance.ts`'s `spendFlag`), so a + * case here would be dead code that reads as the live one. + */ + + case 'redFlagRuled': + s.clock.pendingDecision = null; + s.clock.decisionAnswer = { kind: 'redFlag', train: e.trainId, flag: e.flag }; + break; + case 'flyingSwitch': { const tray = s.trays.get(e.trayId); const area = areaOf(s, e.player); diff --git a/src/engine/events.ts b/src/engine/events.ts index b2be581..4822b93 100644 --- a/src/engine/events.ts +++ b/src/engine/events.ts @@ -87,7 +87,12 @@ export type GameEvent = /** `variant` is the chosen orientation (Gap 11); it must be replayable, so it rides the event. */ | { type: 'cardPlayed'; player: PlayerIndex; cardId: CardId; placement?: GridCoord; variant?: number } | { type: 'mainlineModified'; player: PlayerIndex; cardId: CardId; node: number; key: string; became?: string } - | { type: 'redFlagsSet'; player: PlayerIndex; cardId: CardId; trayId: TrayId; node: number } + /** §Q (Gitea#19) — a flag planted on one side of a district's Limits. */ + | { type: 'redFlagsSet'; player: PlayerIndex; cardId: CardId; seat: SeatIndex; side: Direction } + /** §Q (Gitea#19) — the flag stopped a train and came down with it. One card, one train. */ + | { type: 'redFlagSpent'; seat: SeatIndex; side: Direction; trainNumber: number } + /** §Q (Gitea#19) — the district's owner answered the out-of-phase "flag against this train?". */ + | { type: 'redFlagRuled'; player: PlayerIndex; trainId: TrayId; flag: boolean } | { type: 'trainsDestroyed'; player: PlayerIndex; diff --git a/src/engine/intents.ts b/src/engine/intents.ts index 4d6cd10..5e1d22c 100644 --- a/src/engine/intents.ts +++ b/src/engine/intents.ts @@ -120,10 +120,24 @@ export type Intent = */ | { type: 'mainline.modify'; cardId: CardId; node: number } /** - * Red Flags — protect a stopped train. The flagged train cannot be hit; an approaching train is - * held instead of colliding. + * §Q, RED FLAGS (Gitea#19) — plant a flag on one side of your own district. + * + * "If played, asked FLAG EAST or FLAG WEST. That stops all trains from entering your limits from + * that direction (i.e. Flag East holds westbound trains). You can do this if you see a problem or + * wish to complete switching." + * + * `side` names the side of the district the flag goes on, so a train arriving from that side is + * held. It REPLACES the old rule, which was played on a stopped train out on the Mainline and + * protected it from a rear-ender: measured at 4,212 offers and 4 plays across 600 games, a + * mechanic nobody used. ABS Signals already protects a train standing on a Mainline card. */ - | { type: 'maneuver.redFlags'; cardId: CardId; trayId: TrayId } + | { type: 'maneuver.redFlags'; cardId: CardId; side: Direction } + /** + * The same card, played OUT OF PHASE at the moment of danger (Gitea#19) — "COLLISION RISK! FLAG + * AGAINST T2?". Answers a pending `redFlag` decision; `flag: false` declines and lets the + * collision happen. The side is not asked for: the train is already coming from one. + */ + | { type: 'mainline.redFlag'; flag: boolean; cardId?: CardId } /** * Flying Switch — cut cars off behind the engine and roll them into an adjacent industry, without * the engine entering it. @@ -312,7 +326,11 @@ export type RejectionCode = /** §3.3 (Gitea#11) — this seat has already voted on this extension. */ | 'ALREADY_VOTED' /** §11 (Gitea#5) — answering a Yard Office offer that is not open. */ - | 'NO_YARD_OFFICE_OFFER'; + | 'NO_YARD_OFFICE_OFFER' + /** §Q (Gitea#19) — answering a Red Flag prompt that is not open. */ + | 'NO_RED_FLAG_PROMPT' + /** §Q (Gitea#19) — this district already has a flag on that side. */ + | 'ALREADY_FLAGGED'; export type Rejection = { code: RejectionCode; message: string }; diff --git a/src/engine/legal.ts b/src/engine/legal.ts index 2ac3845..99ef11a 100644 --- a/src/engine/legal.ts +++ b/src/engine/legal.ts @@ -95,6 +95,10 @@ function candidates(s: GameState, player: PlayerIndex): Intent[] { out.push({ type: 'mainline.yardOffice', take: true }); out.push({ type: 'mainline.yardOffice', take: false }); } + if (s.clock.pendingDecision?.kind === 'redFlag') { + out.push({ type: 'mainline.redFlag', flag: true }); + out.push({ type: 'mainline.redFlag', flag: false }); + } switch (s.clock.phase) { case 'localOps': @@ -115,7 +119,8 @@ function candidates(s: GameState, player: PlayerIndex): Intent[] { for (const cardId of s.decks.hands.get(player) ?? []) { const k = s.cards.get(cardId)?.kind; if (k?.kind !== 'maneuver' || k.key !== 'redFlags') continue; - for (const [trayId] of s.trays) out.push({ type: 'maneuver.redFlags', cardId, trayId }); + // §Q (Gitea#19) — a flag goes on one side of your own district, so the only choice is which. + for (const side of ['east', 'west'] as const) out.push({ type: 'maneuver.redFlags', cardId, side }); } out.push({ type: 'redFlag.play' }); diff --git a/src/engine/state.ts b/src/engine/state.ts index fdd3fa7..e23c5f0 100644 --- a/src/engine/state.ts +++ b/src/engine/state.ts @@ -525,10 +525,23 @@ export type DivisionNode = * "Player sets orientation", so the direction is chosen when the card is placed. */ gradeUp?: Direction; - /** Red Flags protecting a stopped train here, by tray. */ - redFlagged?: TrayId[]; } - | { kind: 'office'; seat: SeatIndex }; + | { + kind: 'office'; + seat: SeatIndex; + /** + * §Q, RED FLAGS (Gitea#19) — the side of this district a flag is planted on. + * + * "If played, asked FLAG EAST or FLAG WEST. That stops all trains from entering your limits + * from that direction (i.e. Flag East holds westbound trains)." So the value names the SIDE, + * and a train arriving from that side is held: a westbound train comes from the east. + * + * SPENT ON THE TRAIN IT STOPS (Jesse's ruling, 2026-08-29). One card, one train — the flag + * comes down as it is used, so there is no lifting action to build, nothing to forget, and a + * flag cannot quietly strangle the Division. + */ + redFlag?: Direction; + }; /** Ordered west to east. For N players: N Office nodes and N+1 Mainline cards. */ export type Division = { nodes: DivisionNode[] }; @@ -610,7 +623,12 @@ export type PendingDecision = * §11 (Gitea#5) — an inbound freight may take the Yard Office instead of the Train Order Office. * Asked of whoever sits in `seat`, on the Mainline Phase the train arrives. */ - | { kind: 'yardOffice'; train: TrayId; seat: SeatIndex }; + | { kind: 'yardOffice'; train: TrayId; seat: SeatIndex } + /** + * §Q (Gitea#19) — a train is about to enter this district into a collision, and its owner holds a + * Red Flags card. "You can play the card normally or out of phase, but only if you need it." + */ + | { kind: 'redFlag'; train: TrayId; seat: SeatIndex; from: Direction }; /** * The answer, waiting to be consumed by the train that asked. @@ -621,7 +639,8 @@ export type PendingDecision = */ export type DecisionAnswer = | { kind: 'clearance'; train: TrayId; allow: boolean } - | { kind: 'yardOffice'; train: TrayId; take: boolean }; + | { kind: 'yardOffice'; train: TrayId; take: boolean } + | { kind: 'redFlag'; train: TrayId; flag: boolean }; export type Clock = { day: number; @@ -1161,6 +1180,15 @@ export function playerAtSeat(state: GameState, seat: SeatIndex): PlayerIndex { return p; } +/** This seat's node on the Division — where its Limits, and any Red Flag on them, live. */ +export function officeNodeFor( + state: GameState, + seat: SeatIndex, +): Extract | null { + for (const n of state.division.nodes) if (n.kind === 'office' && n.seat === seat) return n; + return null; +} + /** * WHO MUST ANSWER the interruption, or null when nothing is pending. * diff --git a/src/sim/bot.ts b/src/sim/bot.ts index 11c9adc..e097834 100644 --- a/src/sim/bot.ts +++ b/src/sim/bot.ts @@ -170,7 +170,7 @@ export function makeDeveloperBot(tweaks: BotTweaks): BotPolicy { // Red Flags come before anything else — protection is only worth playing at the moment the // collision is actually pending, and that moment passes. const flags = worthFlagging(s, options); - if (flags) return because('a train of ours is stopped on a Mainline card with another train on it — Red Flags now or not at all', flags); + if (flags) return because('the engine says this arrival collides, and we hold a Red Flag — now or never', flags); // --- Load/Unload: spend every worker, then end. Each is a point, or a step toward one. // @@ -1043,19 +1043,20 @@ function facilityWantsAt( } /** - * Red Flags — "any time". Worth spending only when a train of ours is stopped out on the Mainline - * with another train on the same card, which is the situation that becomes a rear-ender. + * §Q, RED FLAGS (Gitea#19) — spent only at the moment of danger. + * + * The card was redefined: it plants a directional flag on your own Limits rather than protecting a + * stopped train out on the Mainline, so the old heuristic ("is a train of ours sharing a Mainline + * card") no longer describes anything the card does. + * + * The bot now flags ONLY through the out-of-phase prompt, which the engine raises exactly when an + * arrival would collide (`redFlagStop`). That is a better policy than the old one and a much + * simpler one: the engine has already established the danger, so there is nothing for the bot to + * judge. It never plants a flag speculatively — it cannot tell whether it wants time to switch, and + * a flag spent early is a flag not there when a train is actually bearing down. */ -function worthFlagging(s: GameState, options: Intent[]): Intent | null { - for (const i of options) { - if (i.type !== 'maneuver.redFlags') continue; - const tray = s.trays.get(i.trayId); - if (!tray || tray.position.at !== 'mainline') continue; - const node = s.division.nodes[tray.position.index]; - if (node?.kind !== 'mainline') continue; - if (node.transits.length > 1) return i; - } - return null; +function worthFlagging(_s: GameState, options: Intent[]): Intent | null { + return options.find((i) => i.type === 'mainline.redFlag' && i.flag === true) ?? null; } /** A one-line account of which Load/Unload action was taken, and why it ranked first. */ diff --git a/src/sim/narrate.ts b/src/sim/narrate.ts index 0aaf06e..85680ec 100644 --- a/src/sim/narrate.ts +++ b/src/sim/narrate.ts @@ -218,10 +218,19 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration { ? `Realignment: Mainline card ${e.node} converted to ${e.became}` : `Played ${e.key} on Mainline card ${e.node}`, }; + case 'redFlagSpent': + return { + tone: 'good', + text: `RED FLAG — Train ${e.trainNumber} stopped short of the ${e.side === 'east' ? 'Eastern' : 'Western'} Limits. The flag comes down with it.`, + }; + case 'redFlagRuled': + return e.flag + ? { tone: 'plain', text: `Player ${e.player} flagged the approaching train` } + : { tone: 'plain', text: `Player ${e.player} waved the train through` }; case 'redFlagsSet': return { tone: 'good', - text: `Red Flags set out to protect train ${e.trayId} on Mainline card ${e.node} — an approaching train must stop`, + text: `RED FLAGS set out on the ${e.side === 'east' ? 'Eastern' : 'Western'} Limits — the next train from that way is held short`, }; case 'flyingSwitch': return { diff --git a/src/sim/view.ts b/src/sim/view.ts index 04c4822..e2cffb1 100644 --- a/src/sim/view.ts +++ b/src/sim/view.ts @@ -1043,7 +1043,15 @@ export function describeIntent(s: GameState, i: Intent): string { return `${cardName(s, i.cardId)} on ${shortWhere} — ${where}${effect ? `; ${effect}` : ''}`; } case 'maneuver.redFlags': - return `set Red Flags to protect ${trainName(s, i.trayId)} — an approaching train must stop short`; + return ( + `FLAG ${i.side === 'east' ? 'EAST' : 'WEST'} — hold the next ${i.side === 'east' ? 'westbound' : 'eastbound'} ` + + 'train short of your Limits, so you can finish switching' + ); + // §Q, the out-of-phase play (Gitea#19) — "COLLISION RISK! FLAG AGAINST T2?" + case 'mainline.redFlag': + return i.flag + ? 'FLAG IT — stop the train short of your Limits, spending a Red Flags card' + : 'wave it through — let it come in'; /** * §11, the Yard Office (Gitea#5). The offer interrupts the Mainline Phase, so the label has to * carry the whole question — there is no surrounding context on screen to lean on, and the @@ -1109,7 +1117,15 @@ export function describeIntent(s: GameState, i: Intent): string { case 'redFlag.play': return 'play your red flag'; case 'maneuver.redFlags': - return `set Red Flags to protect ${trainName(s, i.trayId)} — an approaching train must stop short`; + return ( + `FLAG ${i.side === 'east' ? 'EAST' : 'WEST'} — hold the next ${i.side === 'east' ? 'westbound' : 'eastbound'} ` + + 'train short of your Limits, so you can finish switching' + ); + // §Q, the out-of-phase play (Gitea#19) — "COLLISION RISK! FLAG AGAINST T2?" + case 'mainline.redFlag': + return i.flag + ? 'FLAG IT — stop the train short of your Limits, spending a Red Flags card' + : 'wave it through — let it come in'; /** * §11, the Yard Office (Gitea#5). The offer interrupts the Mainline Phase, so the label has to * carry the whole question — there is no surrounding context on screen to lean on, and the diff --git a/test/events.test.ts b/test/events.test.ts index 6813abf..2df40da 100644 --- a/test/events.test.ts +++ b/test/events.test.ts @@ -48,6 +48,15 @@ const KNOWN_UNREDUCED = [ 'dispatchBonusUsed', 'expediteFault', 'phaseBegan', + /** + * §Q, Red Flags (Gitea#19). The flag comes down inside the phase driver as it stops a train, so + * this is described rather than reduced like everything else here. + * + * ADDED DELIBERATELY, and it cost a bug first: the flag was originally taken down in a `reduce` + * case, which never fires for an event `advance.ts` emits — so it stayed up and held every train + * that came. That is precisely the failure this list exists to make visible. + */ + 'redFlagSpent', // Employee Rotation moves `seating` in the phase driver and then describes what it did, which is // the pattern every entry on this list follows. 'seatsRotated', diff --git a/test/mainline-cards.test.ts b/test/mainline-cards.test.ts index 3fdee5e..bd6718b 100644 --- a/test/mainline-cards.test.ts +++ b/test/mainline-cards.test.ts @@ -28,7 +28,7 @@ import { import { createGame } from '../src/engine/setup.ts'; import type { GameEvent } from '../src/engine/events.ts'; import type { GameConfig, GameState, GridCoord, TrackCard } from '../src/engine/state.ts'; -import { coordKey, turnOf } from '../src/engine/state.ts'; +import { coordKey, decisionActor, turnOf } from '../src/engine/state.ts'; import { snapshot } from '../src/sim/view.ts'; const config: GameConfig = { @@ -317,77 +317,177 @@ describe('Realignment converts one Mainline type to another', () => { }); }); -describe('Red Flags protect a stopped train', () => { - /** A slow train `behind` closing on a stopped train `ahead`, both eastbound on node 1. */ - function rearEnder(s: GameState) { - const node = pinned(s, 1, 'plains'); - node.transits.push({ tray: 'ahead', stagesRemaining: 2, stagesTotal: 2, direction: 'east' }); - s.trays.set('ahead', { - id: 'ahead', trainNumber: 4, trainIsExtra: false, engineAt: 0, - consist: [], direction: 'east', position: { at: 'mainline', index: 1 }, movesUsed: 0, +describe('Red Flags hold a train out of your Limits (Gitea#19)', () => { + /** + * REPLACES the old rule outright (Jesse, 2026-08-29). Red Flags used to be played on a stopped + * train out on the Mainline and protected it from a rear-ender — measured at 4,212 offers and 4 + * plays across 600 games, a mechanic nobody used. ABS Signals already does that job better. + * + * Now: "If played, asked FLAG EAST or FLAG WEST. That stops all trains from entering your limits + * from that direction (i.e. Flag East holds westbound trains)." Spent on the train it stops — + * one card, one train. + */ + + /** A westbound train one Stage from entering seat 0's district from the east. */ + function approaching(s: GameState) { + const officeIndex = s.division.nodes.findIndex((n) => n.kind === 'office' && n.seat === 0); + const node = pinned(s, officeIndex + 1, 'plains'); + node.transits.push({ tray: 'inbound', stagesRemaining: 1, stagesTotal: 1, direction: 'west' }); + s.trays.set('inbound', { + id: 'inbound', trainNumber: 9, trainIsExtra: false, engineAt: 0, + consist: [{ type: 'hopper', loaded: true }], direction: 'west', + position: { at: 'mainline', index: officeIndex + 1 }, movesUsed: 0, }); - s.trays.set('behind', { - id: 'behind', trainNumber: 2, trainIsExtra: false, engineAt: 0, - consist: [], direction: 'east', position: { at: 'divisionPoint', side: 'west' }, movesUsed: 0, - }); - const dp = s.division.nodes[0]; - if (dp?.kind === 'divisionPoint') dp.holding.push('behind'); s.clock.phase = 'mainline'; s.movedThisPhase = new Set(); - return node; + return s.division.nodes[officeIndex] as Extract; } - it('holds the approaching train instead of letting it close', () => { + it('FLAG EAST holds a westbound train short of the Limits', () => { const s = game(); - const node = rearEnder(s); - node.redFlagged = ['ahead']; + const office = approaching(s); + office.redFlag = 'east'; advance(s); - assert.deepEqual( - s.trays.get('behind')!.position, - { at: 'divisionPoint', side: 'west' }, - 'the flagged train must not be approached', - ); + const pos = s.trays.get('inbound')!.position; + assert.equal(pos.at, 'mainline', 'the flagged train came in anyway'); + assert.ok(!areaOf(s, 0).adOccupancy.includes('inbound'), 'it reached an A/D track'); }); - it('comes in when the protected train rolls', () => { + it('is spent on the train it stops — one card, one train', () => { const s = game(); - const node = rearEnder(s); - node.redFlagged = ['ahead']; - // Bring the protected train to the end of its crossing so it leaves the card. - node.transits[0]!.stagesRemaining = 1; + const office = approaching(s); + office.redFlag = 'east'; - for (let i = 0; i < 12 && (node.redFlagged?.length ?? 0) > 0; i++) advance(s); - assert.deepEqual(node.redFlagged, [], 'flags come in once the train moves off'); + advance(s); + assert.equal(office.redFlag, undefined, 'the flag stayed up after stopping a train'); }); - it('only protects a train out on the Mainline', () => { + it('lets the train in on the next Mainline Phase', () => { + // "Loses one Mainline Phase" — it buys a Stage to clear the lead, not permanent protection. + const s = game(); + const office = approaching(s); + office.redFlag = 'east'; + advance(s); + + s.clock.phase = 'mainline'; + s.movedThisPhase = new Set(); + advance(s); + assert.ok(areaOf(s, 0).adOccupancy.includes('inbound'), 'the train never came in'); + }); + + it('does not hold a train coming from the OTHER side', () => { + // "Flag East holds westbound trains" — an eastbound train arrives from the west. + const s = game(); + const office = approaching(s); + office.redFlag = 'west'; + + advance(s); + assert.ok(areaOf(s, 0).adOccupancy.includes('inbound'), 'a west flag held a train from the east'); + assert.equal(office.redFlag, 'west', 'the wrong-side flag was spent'); + }); + + it('is played on a side, not on a train', () => { const s = game(); - rearEnder(s); s.clock.phase = 'localOps'; s.clock.currentActor = 0; const cardId = hand(s, 'maneuver', 'redFlags'); - assert.equal( - check(s, 0, { type: 'maneuver.redFlags', cardId, trayId: 'behind' }), - 'NO_PLACEMENT', - 'a train sitting at a Division Point cannot be rear-ended', - ); - assert.equal(check(s, 0, { type: 'maneuver.redFlags', cardId, trayId: 'ahead' }), null); + assert.equal(check(s, 0, { type: 'maneuver.redFlags', cardId, side: 'east' }), null); + assert.equal(check(s, 0, { type: 'maneuver.redFlags', cardId, side: 'west' }), null); }); - it('will not double-flag the same train', () => { + it('will not double-flag the same side', () => { const s = game(); - const node = rearEnder(s); s.clock.phase = 'localOps'; s.clock.currentActor = 0; const cardId = hand(s, 'maneuver', 'redFlags'); - node.redFlagged = ['ahead']; + const officeIndex = s.division.nodes.findIndex((n) => n.kind === 'office' && n.seat === 0); + const office = s.division.nodes[officeIndex] as { redFlag?: string }; + office.redFlag = 'east'; - assert.equal( - check(s, 0, { type: 'maneuver.redFlags', cardId, trayId: 'ahead' }), - 'OPTION_ALREADY_CHOSEN', - ); + assert.equal(check(s, 0, { type: 'maneuver.redFlags', cardId, side: 'east' }), 'ALREADY_FLAGGED'); + assert.equal(check(s, 0, { type: 'maneuver.redFlags', cardId, side: 'west' }), null, + 'the other side should still be free'); + }); +}); + +describe('Red Flags offered at the moment of danger (Gitea#19)', () => { + /** + * "In actual cases of danger… if there is a train or cars on the track and there will be a + * collision, then you break in with a dialog that says COLLISION RISK! FLAG AGAINST T2? This way, + * you can play the card normally or out of phase, but only if you need it." + * + * The engine establishes the danger, so the player is never asked to judge it — which is also why + * the bot can now use this card at all. It is offered ONLY to somebody holding one. + */ + function dangerous(s: GameState, giveCard: boolean) { + const officeIndex = s.division.nodes.findIndex((n) => n.kind === 'office' && n.seat === 0); + const node = pinned(s, officeIndex + 1, 'plains'); + node.transits.push({ tray: 'inbound', stagesRemaining: 1, stagesTotal: 1, direction: 'west' }); + s.trays.set('inbound', { + id: 'inbound', trainNumber: 9, trainIsExtra: false, engineAt: 0, + consist: [{ type: 'hopper', loaded: true }], direction: 'west', + position: { at: 'mainline', index: officeIndex + 1 }, movesUsed: 0, + }); + // A hopper fouling the Running Track: §8.3 makes this arrival a collision. + const area = areaOf(s, 0); + area.grid.get(coordKey(area.officeCoord))!.standing = [{ type: 'hopper', loaded: false }]; + if (giveCard) hand(s, 'maneuver', 'redFlags'); + s.clock.phase = 'mainline'; + s.movedThisPhase = new Set(); + } + + it('breaks in to offer the flag when an arrival would collide', () => { + const s = game(); + dangerous(s, true); + advance(s); + assert.equal(s.clock.pendingDecision?.kind, 'redFlag', 'no prompt before a certain collision'); + assert.equal(decisionActor(s), 0, 'the prompt went to the wrong player'); + }); + + it('flagging holds the train and costs the card', () => { + const s = game(); + dangerous(s, true); + advance(s); + const before = (s.decks.hands.get(0) ?? []).length; + + const r = applyIntent(s, 0, { type: 'mainline.redFlag', flag: true }); + assert.ok(r.ok, 'the flag was refused'); + advance(s); + + assert.equal(s.players[0]!.revenue, 0, 'the collision happened anyway'); + assert.equal(s.trays.get('inbound')!.position.at, 'mainline', 'the train came in regardless'); + assert.equal((s.decks.hands.get(0) ?? []).length, before - 1, 'the card was not spent'); + }); + + it('declining lets the collision happen', () => { + const s = game(); + dangerous(s, true); + advance(s); + + assert.ok(applyIntent(s, 0, { type: 'mainline.redFlag', flag: false }).ok); + advance(s); + assert.equal(s.players[0]!.revenue, -5, 'waving it through did not collide'); + }); + + it('does not offer a flag to a player holding none', () => { + // A prompt with one button is not a choice, and it leaks that a collision is coming. + const s = game(); + dangerous(s, false); + advance(s); + assert.equal(s.clock.pendingDecision, null, 'offered a flag to a player with no card'); + assert.equal(s.players[0]!.revenue, -5, 'the collision should have happened'); + }); + + it('stays quiet when the arrival is safe', () => { + const s = game(); + dangerous(s, true); + // Clear the hazard: nothing fouling the Running Track, and room at the Office. + const area = areaOf(s, 0); + area.grid.get(coordKey(area.officeCoord))!.standing = []; + advance(s); + assert.equal(s.clock.pendingDecision, null, 'interrupted the phase for a safe arrival'); }); }); diff --git a/test/sim.test.ts b/test/sim.test.ts index 6342928..dc49777 100644 --- a/test/sim.test.ts +++ b/test/sim.test.ts @@ -363,21 +363,29 @@ describe('end-of-game statistics', () => { * and so removing this line is what proves the bot has been fixed. */ /** - * RED FLAGS JOINS IT (Gitea#3), and for the same reason — the rule is reachable and the bot will - * not take it. + * RED FLAGS JOINS IT, and the reason CHANGED with Gitea#19 — the exemption stays, but it no + * longer means what it used to. * - * MEASURED over 600 games: `maneuver.redFlags` is OFFERED 4,212 times, first in game 5 — so the - * rule is live and constantly available. The bot PLAYS it 4 times, first in game 252. At 200 - * games this canary sees nothing and calls it unreachable, which is the opposite of the truth. + * IT USED TO MEAN "the bot will not take it": measured over 600 games under the old rule, + * `maneuver.redFlags` was OFFERED 4,212 times and PLAYED 4. The card protected a stopped train + * out on the Mainline, it was always available, and the bot simply declined it. * - * It got rarer for two compounding reasons, neither of them a broken rule: Gitea#14 took Red - * Flags from 5 copies to the sheet's 3, and Gitea#3 shortened most crossings to a single Stage, - * so the window in which a train is STANDING on a Mainline card — the only place the card may be - * played — is now usually one Stage wide. + * SINCE Gitea#19 the bot would take it every time — `worthFlagging` accepts the out-of-phase + * prompt unconditionally, because the engine only raises that prompt when an arrival is + * certainly about to collide, so there is nothing left for the bot to judge. It still never + * plays one. MEASURED after the redesign, 200 solitaire games: `redFlagsSet` fires ZERO times. * - * The bot's unwillingness is the thing worth fixing, and it is in TODO.md under Bot Performance. - * Exempted BY NAME so the other forty-odd checks stay live, and so deleting this line is what - * proves the bot has learned to use it. + * The reason is now arithmetic rather than judgement, and it is worth writing down because it + * says what would actually change it. The prompt needs two things to coincide — an arrival that + * would collide (0.14 collisions per game, so roughly one game in seven) AND the district's + * owner holding a Red Flags card at that moment, out of a three-card hand drawn from 121. The + * bot also never plants a flag speculatively, which is the other half of the card and the half + * a human would use to buy time for switching. + * + * So this canary is measuring deck luck, not reachability. `test/mainline-cards.test.ts` + * exercises both halves of the rule end to end on a hand-built board, which is where the + * behaviour is actually pinned. Removing this line still proves something worth proving — that + * the bot has learned to plant a flag on purpose rather than only when handed one. */ const KNOWN_UNREACHABLE_BY_THE_BOT = ['event flyingSwitch', 'event redFlagsSet']; const found = anomalies(report.perGame);