v0.7.4 — Red Flags hold a train out of your Limits (Gitea#19)

"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."

REPLACES the old rule outright, per Jesse's call. Red Flags used to be played on a
stopped train out on the Mainline and protected it from a rear-ender: offered 4,212
times and played 4 across 600 games, a mechanic nobody used, and ABS Signals already
does that job better. The flag is now planted on one side of your own district and
holds the next train arriving from that side.

SPENT ON THE TRAIN IT STOPS. One card, one train, so there is no lifting action to
build, nothing to forget, and a flag cannot quietly strangle the Division. The held
train loses one Mainline Phase and comes in on the next — it buys a Stage to clear
the lead, which is what "wish to complete switching" asks for.

PLAYABLE OUT OF PHASE, which is the other half of the issue: when an arrival would
certainly collide and the district's owner holds the card, the phase breaks in and
asks. Offered ONLY to somebody holding one — a prompt with a single button is not a
choice, and it would leak that a collision is coming. The danger is read from §8.3's
own two triggers rather than restated, so the prompt cannot offer a flag against a
collision that will not happen.

Built on the decision union Gitea#5 introduced: this adds a `redFlag` case and
nothing else structural.

THE BOT STILL NEVER PLAYS IT, AND I MEASURED RATHER THAN ASSUMED. It now takes the
out-of-phase prompt unconditionally — the engine has already established the danger,
so there is nothing left to judge — and over 200 solitaire games `redFlagsSet` fires
ZERO times. The prompt needs an arrival that would collide (0.14 per game, about one
game in seven) to coincide with holding the card from a three-card hand out of 121.
So the anomaly exemption in sim.test.ts stays, but its comment no longer claims the
bot is unwilling: it is measuring deck luck. What is left to fix is the half of the
card a human would use, planting a flag on purpose to buy switching time, and TODO.md
now says that instead of the old finding.

A BUG WORTH RECORDING, 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 state and then describes it. 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 on it deliberately now,
with the reasoning.

858 tests pass.

Closes #19

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EAgJSmeV8zrMh55Mj85ESb
This commit is contained in:
Jesse.Markowitz
2026-08-29 07:12:36 -04:00
co-authored by Claude Opus 5
parent 228027637b
commit 19a6a47ab6
16 changed files with 550 additions and 124 deletions
+33 -5
View File
@@ -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<DivisionNode, { kind: 'office' }> | 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.
*