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
+9
View File
@@ -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
View File
@@ -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
View File
@@ -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);