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:
co-authored by
Claude Opus 5
parent
228027637b
commit
6caf281593
@@ -19,6 +19,80 @@ page as `v0.1.0 · <sha> · <date>`, 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
+114
-9
@@ -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<DivisionNode, { kind: 'office' }>,
|
||||
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<DivisionNode, { kind: 'office' }>,
|
||||
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.
|
||||
|
||||
+55
-16
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
+22
-4
@@ -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 };
|
||||
|
||||
|
||||
+6
-1
@@ -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' });
|
||||
|
||||
+33
-5
@@ -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.
|
||||
*
|
||||
|
||||
+14
-13
@@ -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. */
|
||||
|
||||
+10
-1
@@ -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 {
|
||||
|
||||
+18
-2
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
+146
-46
@@ -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<typeof s.division.nodes[0], { kind: 'office' }>;
|
||||
}
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+20
-12
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user