Compare commits

...
2 Commits
21 changed files with 2340 additions and 81 deletions
+241 -3
View File
@@ -50,14 +50,252 @@ All nine answers are implemented, **170 tests passing**:
| Q8 Crew Tray cap | Already correct. |
| Q9 Second Section | `newTrain.secondSection` plays on a train due out; an identical train is made up behind it next New Train Phase. The **Sister trains optional rule is removed** from Appendix B. |
**Still not implemented**, and now the largest remaining block: the behaviour of the Enhancement
(18), Mainline-modifier (7) and Maneuver (7) cards, plus the Action (10) and Space-use (12) cards in
multiplayer. All are in the deck; playing them is rejected with `NOT_IMPLEMENTED`.
**Enhancements (18) are now implemented** — placement rules, Small Yard consist re-ordering, ABS
Signals, Yard Office diversion, Interlocking, the Telegraph/Telephone/Radio dispatch bonuses and the
three defensive cards, covered by `test/enhancements.test.ts`.
**Mainline modifiers (7) and Maneuvers (6 of 7) are now implemented**, covered by
`test/mainline-cards.test.ts`. They are *not* multiplayer-only — that was a mistaken impression
given earlier. Brakeman, Airbrakes, Helpers and Realignment all change your own crossing times, and
Red Flags prevents a rear-ender, which happens in solitaire too. The real reason they lagged was
sequencing: Enhancements were done first because they change core loops.
| Card | Where it lives | Effect |
| --- | --- | --- |
| Brakeman, Airbrakes | `mainline.modify` on a grade | 1 Stage downhill each; Airbrakes needs Brakeman on the card |
| Helpers | `mainline.modify` on a grade | 1 Stage uphill |
| Realignment ×2 | `mainline.modify` on any Mainline card | converts per `REALIGNMENTS`; barred while a train is on the card |
| Facing Point Locks ×2 | grid, as the Enhancement | the sheet prints this card in **both** lists — one card, not two |
| Red Flags ×5 | `maneuver.redFlags` on a stopped train | an approaching train is held instead of colliding; flags come in when the train rolls |
| Flying Switch | `maneuver.flyingSwitch` during switching | cuts cars into an adjacent industry without the engine entering; costs a Move |
| Poling | — | **deliberately unimplemented** |
Crossing time never falls below one Stage — a train cannot cross in no time.
**Poling stays unimplemented.** The recovered sheet records its effect as "TBD in the source", so
there is nothing to build. A test asserts it remains TBD, to stop anyone "fixing" it by inventing
an effect; a silent no-op would be worse than a rejection.
**Q11, answered from the source.** The Heavy Grade card prints **"(Up)"** and **"Player sets
orientation"**, so which way it climbs is a property of the placed card, not a fixed compass
direction. `DivisionNode.gradeUp` records the direction a train travels when **climbing**; a train
going the other way is descending. Brakeman and Airbrakes apply to the descent, Helpers to the
climb, so turning the card around swaps which trains each card helps.
One gap remains, and it is a **setup** gap rather than a rules one: `createGame` is synchronous and
returns a ready state, so there is no point at which a player can be asked. Orientation is currently
**rolled** — deterministic from the seed, and roughly even (51/49 east/west across 400 games) — and
should become a real player choice when setup gains an interactive phase.
**Still not implemented**: the Action (10) and Space-use (12) cards, which are genuinely
multiplayer-only. Playing them is rejected with `NOT_IMPLEMENTED`.
### Parking trains — two engine bugs and a bot defect
Trains reached an Office and never left: **668 arrivals against 515 departures**. A train may only
highball from the Office square itself (§8.1, Gap 2b); from anywhere else `moveTrain` returns
`held`, permanently. 77 of 93 leftover crews were sitting somewhere they could never depart from.
Three separate causes, found by following the stranded crews:
1. **Reverse was hardcoded to east/west.** `destinationsFor` computed the backing-out port as
`facing === 'e' ? 'w' : 'e'`. A crew facing *south* reversed to *east* — a port a north-south
card does not have — so it could never back out of a district spur. Now `opposite(facing)`.
2. **Facing was derived from `direction`, which only carries east and west.** A crew standing on a
north-south spur was given an exit port its card did not have, leaving it with no legal moves at
all. `CrewTray.facing` now records the actual port, updated on every move from the port the crew
entered through.
3. **The bot never went home,** and its "no useful work" fallback took an *arbitrary* legal move —
the same shuttling bug as before, re-entered by a different door. It now heads for the Office
whenever nothing productive remains, and a crew stranded away from the Office can claim a whole
Local Operations turn to walk back.
Flying Switch was also mis-modelled: it tested **orthogonal adjacency** when the cut *rolls along
the track*, which both allowed a cut to cross to a cell with no rail between and refused a siding
two cards along the same track. It made the card nearly unplayable — held for 1,400 turns across 60
games and legal on 2. Now the target must be track-connected.
| | before | after |
| --- | --- | --- |
| arrivals / departures | 668 / 515 (77%) | **795 / 793 (99.7%)** |
| leftover crews stranded off the Office | 77 of 93 | 24 of 53 |
| collisions per game | 0.2 | **0.7** |
| revenue per player | 1.0 | **1.1** |
**Revenue got worse, and that is the honest result.** Parked trains were not colliding. With trains
circulating properly, every collision is now `no free A/D track` — 35 of 35 across 60 games — and
collisions cost **3.3** of the 1.1 net. The previous figure was flattered by a bug.
### A/D congestion is not congestion — it is the Whistle Post
Chasing it found no congestion at all. The Office is **empty at 88% of Stage starts** (occupancy 0
at 3,136 of 3,600) and mean dwell on an A/D track is **1.4 Stages**. Collisions are bursts, and they
are concentrated in one place:
- **25 of 26 collisions happen at Whistle Post**, which has **one** A/D track, so any second arrival
is an automatic collision (§8.3, Gap 2a). One collision occurred at Depot; none above it.
- **No collision was ever recorded in a district holding an Interlocking.** The enhancement is a
complete cure — it just arrives too late and too rarely.
- **25 of 100 games never escape Whistle Post at all**, and those are exactly the games that never
drew a Depot card. Mean first upgrade is Stage **9.3 of 60**.
The damage is concentrated, not spread:
| | games | mean revenue | worst |
| --- | --- | --- | --- |
| never upgraded past Whistle Post | 25 | **6.0** | 54 |
| upgraded at least once | 75 | **0.4** | +9 (best) |
That is what makes the median **+1.0** while the mean is **1.0**: a quarter of games are ruined by
a draw that never comes. The bot now plays an Office upgrade ahead of everything else and prefers
Interlocking over other Enhancements; both were previously unranked. Neither moves the mean, because
the constraint is **draw luck, not policy** — escaping Whistle Post requires one of **4 Depot cards
in 133**.
### Q12 answered — office density doubled
**Players always start at a Whistle Post**; that is settled and not up for change. The lever is
therefore card density. Office `copiesInDeck` is **doubled**: Depot 4→8, Station 2→4, Terminal 1→2.
Offices go 7 → 14 and the deck 133 → **140** (solitaire 111 → **118**).
Station and Terminal were doubled alongside Depot rather than lifting Depot alone, because upgrades
are strictly sequential (Gap 3b): the ladder has to stay a pyramid or players reach a rung whose
next card is scarcer than the one that got them there. The test now asserts that **property**
instead of the literal counts, so it survives the next density change.
| | 7 offices / 133 | 14 offices / 140 |
| --- | --- | --- |
| revenue per player | 1.0 | **0.7** |
| games never leaving Whistle Post | 25 | **13** |
| mean revenue, games that did upgrade | 0.4 | **1.9** |
| collisions per game | 0.7 | **0.3** |
| lost to collisions | 3.3 | **1.6** |
| worst game | 48 | 28 |
All 37 remaining collisions are still at Whistle Post, which confirms the diagnosis rather than
undermining it. Note the games that *still* never upgrade got worse (6.0 → 10.5): they are now a
smaller, unluckier group, so the mean is both more extreme and noisier. **Still 0 wins.**
> **PROVISIONAL — re-evaluate later.** These counts were chosen to remove a 25% chance of an
> unwinnable opening deal, not taken from the recovered design, and the instrument is blunt: it
> lifts the whole office ladder and dilutes every other category slightly. Revisit once the victory
> target is settled and freight carries its intended share. The better answer may turn out to be
> fewer Terminals, a cheaper first upgrade, or more A/D capacity at the Whistle Post itself.
### Measuring, after getting it wrong
A throwaway probe reported "60 of 60 games never upgraded past Whistle Post" while the Office-tier
histogram from the same run plainly showed Depots, Stations and Terminals. Events reach the log from
**two** sources — the phase driver (`pump`) and player intents (`applyIntent`) — and the probe
watched only the first. Office upgrades arrive through the second.
The cause was hand-rolling the drive loop. `playGame` already tallied both sources correctly; it was
re-implemented because it returns the event log but not the *state at the moment an event fired*,
which is what questions like "what tier was the Office at this collision" need.
`playGame` now takes an optional `PlayObserver``(event, state) => void`, called from the single
point both sources funnel through. Two tests guard it: one asserts the observer sees **exactly** the
events the log records, one asserts office upgrades are visible in the log across 25 games.
Reintroducing the original mistake fails both.
**A wrong measurement is worse than a missing one, because it gets believed and acted on.**
### Freight throughput — a self-inflicted deadlock
Freight was 10% of gross revenue with 27 industries on the board, so the Gap 12 tripling had bought
facilities but not freight. The pipeline said why: **415 loads started, 413 unjams, 13 completions**
across 60 games — a **3%** completion rate, with the Freight Agent spending more than half its
actions clearing jams it had caused.
A load leaves MEN|AT|WORK only onto a **spotted empty car of its own type** (§9.3). Starting one with
no car spotted parks it on WORK — and a load on WORK **locks the industry track**, which blocks the
very car that would clear it. The bot started a load whenever the intent was legal, so it jammed its
own facilities and then spent its turns unjamming them.
Two fixes, both in the bot; the engine was right throughout.
- **Only start a load that can finish.** `loadCanFinish` requires the receiving car to be spotted
already. Worth recording that the first attempt kept a last-resort `startLoad` "rather than idle a
Laborer" and measured *identical* to having no gate at all — the fallback was taken every time. An
idle Laborer is strictly better than a jam, which costs an action to clear and locks the track
meanwhile.
- **Weigh the whole consist, not just the back car.** 744 switch turns produced 135 Moves and 741
immediate ends: a crew holding wanted cars behind one unwanted car concluded there was nothing to
do. §A.3 makes the back car the only *droppable* one; it does not make it the only one worth
travelling for. The crew now sets out dead weight on a plain spur **before** travelling — ordering
that matters, because doing it after produced the shuttling loop for the third time.
| | before | after |
| --- | --- | --- |
| loads started / completed | 415 / 13 (**3%**) | 44 / 38 (**86%**) |
| unjams | 413 | 281 |
| cars dropped | 151 | 197 |
| cars coupled | 14 | 25 |
| unloads completed | 40 | 74 |
| **revenue per player** | 0.7 | **2.2** |
| cards played | 12.4 | 16.9 |
| trains scheduled | 2.0 | 2.7 |
| passenger revenue | 1.3 | 2.7 |
Freeing the Laborers freed the whole turn economy — passenger revenue doubled without a single
passenger rule changing. **Still 0 wins against a target of 20**, and freight is still only 13% of
gross, so the loop runs now but does not yet carry the game.
### Track was unplayable (found while wiring up Enhancements)
Moving track out of the deck into a per-player supply (§12.2) left **no way to play it**. Districts
could only grow through Freight-Facility and Modifier cards, so the Running-Track and
Secondary-Track *straights* that Enhancements attach to essentially never existed. Two bugs
compounded it: `legalActions` offered Enhancements only **empty** grid cells, when an Enhancement
attaches to an **occupied** card.
Measured before the fix: a hand held a playable Enhancement on **4,778** turns and could legally
place one on **33** — and those 33 were all ABS Signals, which targets a Mainline card rather than
the grid.
Fixed by adding a `track.lay` intent drawing from `OfficeArea.trackSupply`, and by giving
Enhancements the occupied cells as candidates. Enhancement placements went 21 → 98 per 40 games.
**Q10, answered.** Track is laid during the "draw a card" option, **one piece a turn** — which is
how track behaved when it *was* a card.
### Measurement after the answers
100 solitaire Standard games: mean revenue **1.3**, trains scheduled **2.8**, cards played **11.3**.
After Enhancements and the track fix: mean revenue **0.0**, trains scheduled **2.9**, cards played
**13.9**, collisions **0.3**. Still **0 wins against a target of 20**.
### Gap 12 resolved — industry density
The shortfall was structural rather than a missing rule. Freight was 10% of gross revenue and
`carsCoupled` fired **4 times in 100 games**: the chain needs a Freight Facility, an empty car of
its exact type spotted on its industry track, three Laborer advances, and then a crew with spare
capacity to lift the load — and there were only **1.6 freight facilities per game**, because the
recovered sheet puts **9 industries in a 115-card deck** where the prototype had 10 in 52.
**Every industry's `copies` is tripled: 9 → 27, deck 115 → 133** (solitaire 93 → 111). Tripling
uniformly restores roughly the prototype ratio while preserving the sheet's proportions exactly —
the outbound/inbound balance and the lockout structure are untouched.
`ROLLING_STOCK_SUPPLY` scaled with it, from 62 pieces to **80**: worst-case demand at 27 industries
is boxcar 15, hopper 12, tank 9, reefer 6, and a Division Yard that runs dry would starve the very
loop the increase exists to feed. That supply was already flagged as provisional, not transcribed.
| | 9 industries / 115 | 27 industries / 133 |
| --- | --- | --- |
| revenue per player | 0.0 | **1.1** |
| freight facilities per game | 1.6 | **3.6** |
| freight share of gross | 10% | **18%** |
| laborer actions | 9.8 | **16.9** |
| collisions | 0.3 | **0.1** |
| trains scheduled | 2.9 | **2.1** |
First positive mean revenue recorded, and the `carsCoupled` anomaly cleared. **Still 0 wins against
a target of 20**, so this is progress on a diagnosis, not a balanced game. Note the side effect:
trains scheduled fell 2.9 → 2.1, because 22 train cards in 133 are drawn less often than 22 in 115.
Train density may need the same treatment.
Still no wins, and still **not a verdict** — roughly a third of the deck does nothing yet, and the
Small Yard enhancement (which is what makes the switching puzzle solvable) is among the unimplemented
cards. Gap 12 stays invalidated until the enhancements are in.
+115 -2
View File
@@ -19,6 +19,7 @@
import {
COLLISION_PENALTY,
MAINLINE_PROFILES,
enhancementRule,
consistSize,
crossingStages,
trainProfile,
@@ -349,11 +350,58 @@ function enterMainline(
): void {
const profile = trainProfile(tray.trainNumber ?? 0, tray.trainIsExtra);
const carriesPassengers = tray.consist.some((c) => c.type === 'coach');
const stages = crossingStages(node.card, profile?.speed ?? 'slow', carriesPassengers);
const stages = crossingStages(
node.card,
profile?.speed ?? 'slow',
carriesPassengers,
node.modifiers ?? [],
tray.direction,
node.gradeUp ?? 'east',
);
node.transits.push({ tray: id, stagesRemaining: stages, direction: tray.direction });
tray.position = { at: 'mainline', index };
}
/**
* Telegraph / Telephone / Radio — "once a day, when dispatching facing trains, add +N to the other
* train's number". Spends the best available device the owning player has, once per Day each.
*
* Returns the bonus applied to the opposing train's number (0 if none was available or needed).
*/
function spendDispatchBonus(
s: GameState,
tray: CrewTray,
other: CrewTray,
events: GameEvent[],
): number {
const mine = tray.trainNumber ?? 99;
const theirs = other.trainNumber ?? 99;
const area = s.officeAreas.get(s.clock.superintendent);
if (!area) return 0;
// Best device first — Radio (+12) beats Telephone (+8) beats Telegraph (+4).
for (const key of ['radio', 'telephone', 'telegraph'] as const) {
if (area.dispatchUsedToday.includes(key)) continue;
const present = [...area.grid.values()].some((c) => c.enhancements.includes(key));
if (!present) continue;
const bonus = enhancementRule(key)?.dispatchBonus ?? 0;
// Spend it only if it actually wins the meet — the device makes the OTHER train count as
// junior, so ours may proceed.
if (mine >= theirs + bonus) continue;
area.dispatchUsedToday.push(key);
events.push({
type: 'dispatchBonusUsed',
key,
bonus,
trainNumber: tray.trainNumber ?? 0,
againstTrain: other.trainNumber ?? 0,
});
return bonus;
}
return 0;
}
/** Q3 — an expedited train does not spend a Stage standing at the Office. */
function isExpedited(tray: CrewTray): boolean {
return trainProfile(tray.trainNumber ?? 0, tray.trainIsExtra)?.rules.expedite === true;
@@ -449,6 +497,8 @@ function moveTrain(
const target = index + dir;
const dest = s.division.nodes[target];
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';
@@ -503,7 +553,26 @@ function evaluateClearance(
const otherTray = s.trays.get(other);
if (!otherTray) continue;
if (otherTray.direction !== tray.direction) return 'blocked';
if (otherTray.direction !== tray.direction) {
// §8.1 — a train moving TOWARDS the considered train is an absolute bar. That stands.
//
// The exception is dispatching technology. Telegraph/Telephone/Radio exist precisely so the
// Superintendent can arrange a meet with a facing train: "once a day, when dispatching
// facing trains, add +4/+8/+12 to the other train's number". Without a device there is no
// way to pass the order, so the train simply holds.
if (spendDispatchBonus(s, tray, otherTray, events) > 0) return 'clear';
return 'blocked';
}
// Red Flags — "a stopped train is prevented from being hit; the approaching train is prevented
// from moving". Flagging is per-train rather than per-card, so it protects one specific train
// where ABS Signals protects everything on the card.
if ((node.redFlagged ?? []).includes(other)) return 'blocked';
// ABS Signals — "trains on this card will not rear-end each other; they stop short of a
// collision". With signals in place a following train simply holds, and the Superintendent has
// no judgment call to make. This is the amendment to Gap 2's unconditional collisions.
if (node.absSignals) return 'blocked';
// Same direction — the Superintendent must rule (§8.1, fourth condition).
s.clock.pendingDecision = { train: id, occupiedBy: other };
@@ -526,13 +595,55 @@ function arriveAtOffice(
): MoveOutcome {
const area = areaOf(s, owner);
const capacity = officeProfile(area.tier).adTracks;
const hasEnhancement = (key: string): boolean =>
[...area.grid.values()].some((c) => c.enhancements.includes(key));
// Yard Office — "an inbound train with NO COACHES that can make a single move to the yard office
// track may arrive there, not at the Train Order Office". It sidesteps the A/D track entirely.
const noCoaches = !tray.consist.some((c) => c.type === 'coach');
if (noCoaches && hasEnhancement('yardOffice')) {
for (const [key, card] of area.grid) {
if (!card.enhancements.includes('yardOffice')) continue;
const [row, col] = key.split(',').map(Number);
tray.position = { at: 'grid', owner, coord: { row: row!, col: col! } };
events.push({
type: 'trainDiverted',
trainNumber: tray.trainNumber ?? 0,
to: 'the Yard Office',
reason: 'no coaches, so it need not occupy the Train Order Office',
});
return 'moved';
}
}
// Gap 2d — no room at the station is a collision, and it is the local player's fault (§10).
if (area.adOccupancy.length >= capacity) {
// Interlocking — "may stop an inbound train on the Limit Track". Instead of an automatic
// collision, the train is held at the Limits until an A/D track frees up. This is the designed
// answer to the A/D overflow that Gap 2d made fatal.
if (hasEnhancement('interlocking')) {
area.heldAtLimits.push(id);
events.push({
type: 'trainDiverted',
trainNumber: tray.trainNumber ?? 0,
to: 'the Limits',
reason: 'Interlocking held it clear of a full Office instead of a collision',
});
return 'moved';
}
collide(s, owner, [id], events, 'no free A/D track');
return 'moved';
}
// A train held at the Limits takes the first free A/D track before any newcomer.
if (area.heldAtLimits.length > 0 && area.heldAtLimits[0] !== id) {
const first = area.heldAtLimits.shift()!;
area.adOccupancy.push(first);
const held = s.trays.get(first);
if (held) held.position = { at: 'grid', owner, coord: area.officeCoord };
}
area.heldAtLimits = area.heldAtLimits.filter((t) => t !== id);
// §8.3 — cars standing on the Running Track between the Limits and the Office. A train at speed
// is not expecting them (§A.4), so this is a collision too, not a coupling.
const officeCard = area.grid.get(coordKey(area.officeCoord));
@@ -627,6 +738,8 @@ function shiftChange(s: GameState, events: GameEvent[]): AdvanceResult {
}
if (s.clock.stage >= STAGES_PER_DAY) {
// Telegraph/Telephone/Radio are each usable once a Day.
for (const area of s.officeAreas.values()) area.dispatchUsedToday = [];
s.clock.day += 1;
s.clock.stage = 1;
s.collisionsToday = 0;
+386 -5
View File
@@ -19,15 +19,20 @@ import {
HAND_LIMIT,
LABORER_ACTIONS_PER_LOAD,
MAX_CONSIST,
REALIGNMENTS,
enhancementRule,
industryProfile,
mainlineModifierRule,
mainlineProfile,
modifierProfile,
nextOfficeTier,
officeProfile,
} from './content.ts';
import type { CarType, FreightKind, ModifierKind, TrackGeometry } from './content.ts';
import type { CarType, FreightKind, Hand, MainlineKind, ModifierKind, TrackGeometry } from './content.ts';
import type { GameEvent } from './events.ts';
import type { Intent, RejectionCode } from './intents.ts';
import type {
CardId,
Facility,
GameState,
GridCoord,
@@ -44,6 +49,7 @@ import {
canDropCarsAt,
canPlaceAt,
facilityVariants,
opposite,
reachableDestinations,
variantsFor,
} from './track.ts';
@@ -98,6 +104,7 @@ function occupancyFor(s: GameState, player: PlayerIndex, self: TrayId): Occupanc
/** A tray's facing, expressed as the port it would leave by going forward. */
function facingPort(s: GameState, trayId: TrayId): Port {
const tray = s.trays.get(trayId);
if (tray?.facing) return tray.facing;
return tray?.direction === 'west' ? 'w' : 'e';
}
@@ -272,6 +279,25 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
return canDropCarsAt(areaOf(s, player), here, i.count) ? null : 'CANNOT_DROP_HERE';
}
case 'switch.sortConsist': {
if (!inPhase(s, 'localOps')) return 'WRONG_PHASE';
if (s.turn.option !== 'switch') return 'OPTION_NOT_CHOSEN';
if (s.turn.movesRemaining < 1) return 'NO_MOVES_REMAINING';
const tray = s.trays.get(i.trayId);
if (!tray) return 'NO_SUCH_TRAY';
const here = trayCoord(s, i.trayId);
if (!here) return 'ILLEGAL_MOVE';
// Only on a card carrying a Small Yard, and it costs the Move it "spends in the yard".
const card = areaOf(s, player).grid.get(coordKey(here));
if (!card?.enhancements.includes('smallYard')) return 'NOT_CONNECTED';
// The order must be a permutation of the current consist.
if (i.order.length !== tray.consist.length) return 'CONSIST_ORDER';
const seen = new Set(i.order);
if (seen.size !== i.order.length) return 'CONSIST_ORDER';
if (i.order.some((n) => n < 0 || n >= tray.consist.length)) return 'CONSIST_ORDER';
return null;
}
case 'switch.end':
if (!inPhase(s, 'localOps')) return 'WRONG_PHASE';
return s.turn.option === 'switch' ? null : 'OPTION_NOT_CHOSEN';
@@ -307,6 +333,86 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
return null;
}
case 'track.lay': {
if (!inPhase(s, 'localOps')) return 'WRONG_PHASE';
if (s.turn.option !== 'draw') return 'OPTION_NOT_CHOSEN';
if (s.turn.laidThisTurn) return 'OPTION_ALREADY_CHOSEN';
const key = `${i.geometry}:${i.hand}`;
if ((areaOf(s, player).trackSupply.get(key) ?? 0) < 1) return 'NO_SUCH_CARD';
const proto = protoTrackCard(i.geometry, i.hand, i.variant);
if (!proto) return 'NO_PLACEMENT';
return canPlaceAt(areaOf(s, player), i.placement, proto) ? null : 'NOT_CONNECTED';
}
case 'mainline.modify': {
if (!inPhase(s, 'localOps')) return 'WRONG_PHASE';
if (s.turn.option !== 'draw') return 'OPTION_NOT_CHOSEN';
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 !== 'mainlineModifier') return 'WRONG_INTENT';
const rule = mainlineModifierRule(card.kind.key);
if (!rule) return 'NOT_IMPLEMENTED';
const node = s.division.nodes[i.node];
if (!node || node.kind !== 'mainline') return 'NO_PLACEMENT';
const on = node.modifiers ?? [];
if (on.includes(rule.key)) return 'OPTION_ALREADY_CHOSEN';
if (rule.gradeOnly && mainlineProfile(node.card).speed.kind !== 'grade') return 'NOT_A_GRADE';
if (rule.requiresOnCard && !on.includes(rule.requiresOnCard)) return 'NOT_CONNECTED';
// "Not while a train is on it" — realigning under a moving train is exactly the situation the
// restriction exists to prevent.
if (node.transits.length > 0) return 'TRAIN_ON_CARD';
if (rule.key === 'realignment' && !REALIGNMENTS.some((r) => r.from === node.card)) {
return 'NO_PLACEMENT';
}
return null;
}
case 'maneuver.redFlags': {
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';
return null;
}
case 'maneuver.flyingSwitch': {
if (!inPhase(s, 'localOps')) return 'WRONG_PHASE';
if (s.turn.option !== 'switch') return 'OPTION_NOT_CHOSEN';
if (s.turn.movesRemaining < 1) return 'NO_MOVES_REMAINING';
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 !== 'flyingSwitch') return 'WRONG_INTENT';
const tray = s.trays.get(i.trayId);
if (!tray) return 'NO_SUCH_TRAY';
const here = trayCoord(s, i.trayId);
if (!here) return 'CANNOT_DROP_HERE';
if (i.count < 1 || i.count > tray.consist.length) return 'CONSIST_EMPTY';
// The cut is uncoupled and ROLLS to the industry under its own momentum, so the target must
// be somewhere the train could itself have run to — track-connected, not merely a neighbouring
// square. An earlier version tested orthogonal adjacency, which was wrong in both directions:
// it would have allowed a cut to cross to a cell with no rail between, while refusing a siding
// two cards along the same track. It also made the card effectively unplayable — held for
// 1,400 turns across 60 games and legal on 2.
const reachable = [
...destinationsFor(s, player, i.trayId, here, false),
...destinationsFor(s, player, i.trayId, here, true),
];
if (!reachable.some((d) => d.coord.row === i.to.row && d.coord.col === i.to.col)) {
return 'NOT_CONNECTED';
}
const fsArea = areaOf(s, player);
const target = fsArea.grid.get(coordKey(i.to));
if (!target?.facility || target.facility.kind !== 'freight') return 'CANNOT_DROP_HERE';
return canDropCarsAt(fsArea, i.to, i.count) ? null : 'CANNOT_DROP_HERE';
}
case 'draw.end': {
if (!inPhase(s, 'localOps')) return 'WRONG_PHASE';
if (s.turn.option !== 'draw') return 'OPTION_NOT_CHOSEN';
@@ -478,10 +584,29 @@ function checkPlay(
// spots) or it does nothing at all, so placing it anywhere else is not a legal play.
return adjacentFacilityCoord(area, placement) ? null : 'NOT_CONNECTED';
}
case 'spaceUse':
case 'enhancement':
case 'enhancement': {
if (!placement) return 'NO_PLACEMENT';
return checkEnhancementPlacement(s, area, card.kind.key, placement);
}
case 'mainlineModifier':
// Facing Point Locks appears in BOTH the Enhancement and Mainline-modifier lists on the sheet,
// with the same placement and the same effect. It is one card printed twice, so the mainline
// copy uses the enhancement's grid placement rather than going onto a Mainline card.
if (card.kind.key === 'facingPointLocksMainline') {
if (!placement) return 'NO_PLACEMENT';
return checkEnhancementPlacement(s, area, 'facingPointLocks', placement);
}
// The rest are laid on a Mainline card, which is not a grid coordinate — see
// `mainline.modify`.
return 'WRONG_INTENT';
case 'maneuver':
// Red Flags and Flying Switch have their own intents; Poling's effect is recorded as "TBD in
// the source", so there is nothing to implement.
return 'WRONG_INTENT';
case 'spaceUse':
case 'action':
// Recovered from the design but not yet implemented — see docs/rules/implications.md §7 and
// §10. Rejecting is honest: silently accepting would make the card look playable while doing
@@ -503,7 +628,11 @@ function destinationsFor(
) {
const tray = s.trays.get(trayId)!;
const facing = facingPort(s, trayId);
const exit: Port = reverse ? (facing === 'e' ? 'w' : 'e') : facing;
// Reversing is the OPPOSITE port, whichever it is. This was hardcoded to flip between east and
// west, so a crew facing north or south reversed to 'e' — a port a north-south card does not
// have — and could never back out of a district spur. Combined with a facing that was itself
// derived from an east/west direction, it stranded 29 of 62 leftover crews on north-south track.
const exit: Port = reverse ? opposite(facing) : facing;
return reachableDestinations(
{
area: areaOf(s, player),
@@ -529,6 +658,9 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
const from = trayCoord(s, i.trayId)!;
const dests = destinationsFor(s, player, i.trayId, from, i.reverse);
const dest = dests.find((d) => d.coord.row === i.to.row && d.coord.col === i.to.col)!;
// The crew leaves by the port opposite the one it entered through, which is what it will be
// facing when it stops. Without this the facing stays 'e'/'w' forever and a crew that turns
// onto a north-south spur can never move again.
const events: GameEvent[] = [
{
type: 'trayMoved',
@@ -536,6 +668,7 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
from,
to: i.to,
movesRemaining: s.turn.movesRemaining - 1,
facing: opposite(dest.entry),
},
];
if (dest.couples.length > 0) {
@@ -552,6 +685,20 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
return [{ type: 'carsDropped', trayId: i.trayId, at: here, stock }];
}
case 'switch.sortConsist': {
const tray = s.trays.get(i.trayId)!;
const here = trayCoord(s, i.trayId)!;
return [
{
type: 'consistSorted',
trayId: i.trayId,
at: here,
before: tray.consist.map((c) => ({ ...c })),
after: i.order.map((n) => ({ ...tray.consist[n]! })),
},
];
}
case 'switch.end':
case 'draw.end':
return [{ type: 'phaseEnded', player, phase: 'localOps' }];
@@ -607,6 +754,14 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
to: card.kind.tier,
});
}
if (card.kind.kind === 'enhancement' && i.placement) {
events.push({
type: 'enhancementPlaced',
player,
key: card.kind.key,
at: i.placement,
});
}
if (card.kind.kind === 'extraTrain') {
// §7 — an Extra runs once, immediately, as soon as a Crew Tray frees up. Playing one used
// to do nothing at all, which made all four Extra cards dead weight in the deck.
@@ -635,6 +790,47 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
case 'card.discard':
return [{ type: 'cardDiscarded', player, cardId: i.cardId, toSlot: i.toSlot }];
case 'mainline.modify': {
const card = s.cards.get(i.cardId)!;
const key = card.kind.kind === 'mainlineModifier' ? card.kind.key : '';
const node = s.division.nodes[i.node];
const became =
key === 'realignment' && node?.kind === 'mainline'
? REALIGNMENTS.find((r) => r.from === node.card)?.to
: undefined;
return [
{ type: 'mainlineModified', player, cardId: i.cardId, node: i.node, key, ...(became ? { became } : {}) },
];
}
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.flyingSwitch': {
const tray = s.trays.get(i.trayId)!;
// §A.3 — cars come off the back, same as a normal drop.
const stock = tray.consist.slice(tray.consist.length - i.count);
return [{ type: 'flyingSwitch', player, cardId: i.cardId, trayId: i.trayId, to: i.to, stock }];
}
case 'track.lay': {
const key = `${i.geometry}:${i.hand}`;
return [
{
type: 'trackLaid',
player,
geometry: i.geometry,
hand: i.hand,
at: i.placement,
variant: i.variant ?? 0,
remaining: (areaOf(s, player).trackSupply.get(key) ?? 1) - 1,
},
];
}
case 'freightAgent.stockOutbound':
return [
{ type: 'stockToOutbound', player, at: i.at, stock: { type: i.carType, loaded: true } },
@@ -763,6 +959,7 @@ export function reduce(s: GameState, e: GameEvent): void {
const tray = s.trays.get(e.trayId)!;
const owner = tray.position.at === 'grid' ? tray.position.owner : 0;
tray.position = { at: 'grid', owner, coord: e.to };
if (e.facing) tray.facing = e.facing;
s.turn.movesRemaining = e.movesRemaining;
// An A/D track is held only while the train is actually standing at the Office (§2.1).
@@ -787,6 +984,14 @@ export function reduce(s: GameState, e: GameEvent): void {
break;
}
case 'consistSorted': {
const tray = s.trays.get(e.trayId)!;
tray.consist = e.after.map((c) => ({ ...c }));
// "Spends one move in the yard" — the sort costs a Move.
s.turn.movesRemaining = Math.max(0, s.turn.movesRemaining - 1);
break;
}
case 'carsDropped': {
const tray = s.trays.get(e.trayId)!;
const area = areaOf(s, tray.position.at === 'grid' ? tray.position.owner : 0);
@@ -833,6 +1038,7 @@ export function reduce(s: GameState, e: GameEvent): void {
standing: [],
facility: null,
modifiers: [],
enhancements: [],
});
applyModifier(area, e.placement, card.kind.modifier);
} else if (card.kind.kind !== 'office') {
@@ -841,6 +1047,52 @@ export function reduce(s: GameState, e: GameEvent): void {
break;
}
case 'mainlineModified': {
const node = s.division.nodes[e.node];
if (node?.kind === 'mainline') {
if (e.became) node.card = e.became as MainlineKind;
else node.modifiers = [...(node.modifiers ?? []), e.key];
}
spendCard(s, e.player, e.cardId);
break;
}
case 'redFlagsSet': {
const node = s.division.nodes[e.node];
if (node?.kind === 'mainline') {
node.redFlagged = [...(node.redFlagged ?? []), e.trayId];
}
spendCard(s, e.player, e.cardId);
break;
}
case 'flyingSwitch': {
const tray = s.trays.get(e.trayId);
const area = areaOf(s, e.player);
const card = area.grid.get(coordKey(e.to));
if (tray && card) {
tray.consist = tray.consist.slice(0, tray.consist.length - e.stock.length);
const track = card.facility?.industryTrack;
if (track) track.cars.push(...e.stock);
else card.standing.push(...e.stock);
}
s.turn.movesRemaining -= 1;
spendCard(s, e.player, e.cardId);
break;
}
case 'trackLaid': {
const area = areaOf(s, e.player);
area.trackSupply.set(`${e.geometry}:${e.hand}`, e.remaining);
s.turn.laidThisTurn = true;
const built = protoTrackCard(e.geometry as never, e.hand as never, e.variant);
if (built) {
area.grid.set(coordKey(e.at), built);
extendLimitsIfNeeded(area, e.at);
}
break;
}
case 'officeUpgraded': {
// Gap 8 — a property change, NOT a card swap. Swapping would orphan attached Secondary Track.
const area = areaOf(s, e.player);
@@ -897,6 +1149,19 @@ export function reduce(s: GameState, e: GameEvent): void {
break;
}
case 'enhancementPlaced': {
const rule = enhancementRule(e.key);
if (rule?.placement === 'mainlineCard') {
const node = s.division.nodes[e.at.col];
// ABS Signals: trains on this card stop short rather than rear-ending each other.
if (node?.kind === 'mainline') node.absSignals = true;
} else {
const card = areaOf(s, e.player).grid.get(coordKey(e.at));
if (card) card.enhancements.push(e.key);
}
break;
}
case 'extraQueued':
s.pendingExtras.push(e.trainNumber);
break;
@@ -1032,7 +1297,7 @@ function protoCard(
kind: { kind: string; geometry?: string; facility?: string },
variant: number | undefined,
): TrackCard | null {
const base = { standing: [], facility: null, modifiers: [] };
const base = { standing: [], facility: null, modifiers: [], enhancements: [] };
if (kind.kind === 'track') {
const geometry = kind.geometry as TrackGeometry;
@@ -1117,6 +1382,122 @@ function adjacentFacilityCoord(area: OfficeArea, coord: GridCoord): GridCoord |
return null;
}
/**
* The three defensive Enhancements protect against cards that only exist in a multiplayer deck, so
* their placement and state are implemented and their effect is read at the point of attack:
*
* - **Facing Point Locks** — prevents `Derail` being played on you (Action card).
* - **Water Column** — lets you remove a Watertower from your district (Space-use card).
* - **Overpass** — removes the restrictions of a played Railroad Crossing (Action card).
*
* In solitaire the opponent-directed cards are not in the deck (Q6), so these never fire. They are
* queried here rather than being special-cased at each attack site.
*/
export function hasDistrictEnhancement(area: OfficeArea, key: string): boolean {
return [...area.grid.values()].some((c) => c.enhancements.includes(key));
}
/** Facing Point Locks blocks a Derail played at this district. */
export function isProtectedFromDerail(area: OfficeArea): boolean {
return hasDistrictEnhancement(area, 'facingPointLocks');
}
/** A Water Column lets its owner clear a Watertower off their own grid. */
export function watertowersRemovable(area: OfficeArea): GridCoord[] {
if (!hasDistrictEnhancement(area, 'waterColumn')) return [];
const out: GridCoord[] = [];
for (const [key, card] of area.grid) {
if (card.geometry.kind !== 'spaceUse' || card.geometry.key !== 'watertower') continue;
const [row, col] = key.split(',').map(Number);
out.push({ row: row!, col: col! });
}
return out;
}
/**
* Enhancements have per-card placement rules (implications.md §7):
* - Interlocking, Water Column, Telegraph → a Running Track straight
* - Yard Office, Small Yard → a Secondary Track straight
* - Telephone / Radio → stacked on the card below them
* - Facing Point Locks → needs an Interlocking in the district
* - ABS Signals → a Mainline card, not the Office Area
*/
export function checkEnhancementPlacement(
s: GameState,
area: OfficeArea,
key: string,
placement: GridCoord,
): RejectionCode | null {
const rule = enhancementRule(key);
if (!rule) return 'NOT_IMPLEMENTED';
// ABS Signals goes on a Mainline card; `placement.col` names which one.
if (rule.placement === 'mainlineCard') {
const node = s.division.nodes[placement.col];
return node && node.kind === 'mainline' ? null : 'NOT_CONNECTED';
}
const card = area.grid.get(coordKey(placement));
if (!card) return 'NOT_CONNECTED';
if (card.enhancements.includes(key)) return 'OPTION_ALREADY_CHOSEN';
if (rule.requiresOnSameCard && !card.enhancements.includes(rule.requiresOnSameCard)) {
return 'NOT_CONNECTED';
}
if (rule.requiresInDistrict) {
const present = [...area.grid.values()].some((c) =>
c.enhancements.includes(rule.requiresInDistrict!),
);
if (!present) return 'NOT_CONNECTED';
}
const onRunning = placement.row === area.runningRow;
const isStraight =
card.geometry.kind === 'track' && card.geometry.geometry === 'straight';
switch (rule.placement) {
case 'runningTrackStraight':
return onRunning && isStraight ? null : 'NOT_CONNECTED';
case 'secondaryTrackStraight':
return !onRunning && isStraight ? null : 'NOT_CONNECTED';
case 'onCard':
return null;
default:
return 'NOT_CONNECTED';
}
}
/** Removes a played card from its owner's hand and sends it to the Salvage Yard. */
function spendCard(s: GameState, player: PlayerIndex, cardId: CardId): void {
s.decks.hands.set(player, (s.decks.hands.get(player) ?? []).filter((c) => c !== cardId));
s.decks.salvageYard.push(cardId);
}
/** A track piece from the player's own supply, at the chosen rotation. */
function protoTrackCard(
geometry: TrackGeometry,
hand: Hand,
variant: number | undefined,
): TrackCard | null {
const options = variantsFor(geometry);
const v = options[variant ?? 0];
if (!v) return null;
return {
geometry: {
kind: 'track',
geometry,
...(v.axis ? { axis: v.axis } : {}),
...(v.turnout ? { turnout: v.turnout } : {}),
...(hand !== 'none' ? { hand } : {}),
},
baseOperationalRail: geometry !== 'turnout',
standing: [],
facility: null,
modifiers: [],
enhancements: [],
};
}
/**
* Q4 — would building `kind` here conflict with something already in the district?
* The relation is symmetric, so checking either direction is enough.
+150 -20
View File
@@ -105,11 +105,30 @@ export type OfficeProfile = {
* an earlier guess gave one more slot than porters at every tier. Capacity is instead grown by the
* passenger modifier cards (Waiting Area, Restaurant, Hotel).
*/
/**
* Office cards. `copiesInDeck` was **doubled** (Depot 4→8, Station 2→4, Terminal 1→2) — Q12.
*
* Players always start at a Whistle Post, which has ONE A/D track, so a second arrival is an
* automatic collision (§8.3, Gap 2a). Measured at the original density, 25 of 100 games never drew
* a Depot and never escaped: they averaged **6.0** revenue against **0.4** for games that
* upgraded at least once, and 25 of 26 collisions happened at Whistle Post. Escaping needed one of
* 4 Depot cards in 111, roughly a 59% chance across a game's draws.
*
* Upgrades are strictly sequential (Gap 3b, no skipping), so Station and Terminal are rarer than
* their raw counts imply — Terminal needs all three cards in order. Station and Terminal were
* doubled with Depot to keep that ladder in proportion rather than making Depot a special case.
*
* PROVISIONAL — re-evaluate. This was chosen to remove a 25% chance of an unwinnable opening deal,
* not from the recovered design, and it is a blunt instrument: it lifts the whole office ladder and
* dilutes every other category slightly (deck 133 → 140). Revisit once the victory target is
* settled and freight is carrying its intended share; the right answer may instead be fewer
* Terminals, a cheaper first upgrade, or more A/D capacity at Whistle Post.
*/
export const OFFICE_PROFILES: readonly OfficeProfile[] = [
{ tier: 'whistlePost', name: 'Whistle Post', isControlPoint: false, isPassengerFacility: false, adTracks: 1, porters: 0, passengerOut: 0, passengerIn: 0, copiesInDeck: 0 },
{ tier: 'depot', name: 'Depot', isControlPoint: true, isPassengerFacility: true, adTracks: 2, porters: 1, passengerOut: 1, passengerIn: 1, copiesInDeck: 4 },
{ tier: 'station', name: 'Station', isControlPoint: true, isPassengerFacility: true, adTracks: 3, porters: 2, passengerOut: 2, passengerIn: 2, copiesInDeck: 2 },
{ tier: 'terminal', name: 'Terminal', isControlPoint: true, isPassengerFacility: true, adTracks: 4, porters: 3, passengerOut: 3, passengerIn: 3, copiesInDeck: 1 },
{ tier: 'depot', name: 'Depot', isControlPoint: true, isPassengerFacility: true, adTracks: 2, porters: 1, passengerOut: 1, passengerIn: 1, copiesInDeck: 8 },
{ tier: 'station', name: 'Station', isControlPoint: true, isPassengerFacility: true, adTracks: 3, porters: 2, passengerOut: 2, passengerIn: 2, copiesInDeck: 4 },
{ tier: 'terminal', name: 'Terminal', isControlPoint: true, isPassengerFacility: true, adTracks: 4, porters: 3, passengerOut: 3, passengerIn: 3, copiesInDeck: 2 },
];
export const OFFICE_ORDER: readonly OfficeTier[] = ['whistlePost', 'depot', 'station', 'terminal'];
@@ -145,13 +164,23 @@ export type IndustryProfile = {
copies: number;
};
/**
* Industry density (Gap 12). The recovered sheet lists 9 industries in a 115-card deck; the
* prototype ran 10 in 52. At 9-in-115 a game saw 1.6 Freight Facilities, freight was 10% of gross
* revenue, and `carsCoupled` fired 4 times per 100 games — the freight loop, which is the point of
* the game, effectively never ran.
*
* Each industry's `copies` is TRIPLED, giving 27 in 133. That restores roughly the prototype's
* ratio while preserving the sheet's proportions exactly: the outbound/inbound balance and the
* lockout structure are unchanged, because every kind scales by the same factor.
*/
export const INDUSTRY_PROFILES: readonly IndustryProfile[] = [
{ kind: 'freightHouse', name: 'Freight House', carTypes: ['boxcar'], flow: 'both', baseOut: 1, baseIn: 1, baseLoaders: 1, lockouts: ['grocersWarehouse'], copies: 2 },
{ kind: 'mineTipple', name: 'Mine Tipple', carTypes: ['hopper'], flow: 'outbound', baseOut: 1, baseIn: 0, baseLoaders: 1, lockouts: ['powerPlant'], copies: 2 },
{ kind: 'refinery', name: 'Refinery', carTypes: ['tank'], flow: 'outbound', baseOut: 1, baseIn: 0, baseLoaders: 1, lockouts: ['powerPlant'], copies: 1 },
{ kind: 'powerPlant', name: 'Power Plant', carTypes: ['hopper', 'tank'], flow: 'inbound', baseOut: 0, baseIn: 1, baseLoaders: 1, lockouts: ['mineTipple', 'refinery'], copies: 2 },
{ kind: 'packingSheds', name: 'Packing Sheds', carTypes: ['reefer'], flow: 'outbound', baseOut: 1, baseIn: 0, baseLoaders: 1, lockouts: ['grocersWarehouse'], copies: 1 },
{ kind: 'grocersWarehouse', name: "Grocer's Warehouse", carTypes: ['boxcar', 'reefer'], flow: 'inbound', baseOut: 0, baseIn: 1, baseLoaders: 1, lockouts: ['packingSheds', 'freightHouse'], copies: 1 },
{ kind: 'freightHouse', name: 'Freight House', carTypes: ['boxcar'], flow: 'both', baseOut: 1, baseIn: 1, baseLoaders: 1, lockouts: ['grocersWarehouse'], copies: 6 },
{ kind: 'mineTipple', name: 'Mine Tipple', carTypes: ['hopper'], flow: 'outbound', baseOut: 1, baseIn: 0, baseLoaders: 1, lockouts: ['powerPlant'], copies: 6 },
{ kind: 'refinery', name: 'Refinery', carTypes: ['tank'], flow: 'outbound', baseOut: 1, baseIn: 0, baseLoaders: 1, lockouts: ['powerPlant'], copies: 3 },
{ kind: 'powerPlant', name: 'Power Plant', carTypes: ['hopper', 'tank'], flow: 'inbound', baseOut: 0, baseIn: 1, baseLoaders: 1, lockouts: ['mineTipple', 'refinery'], copies: 6 },
{ kind: 'packingSheds', name: 'Packing Sheds', carTypes: ['reefer'], flow: 'outbound', baseOut: 1, baseIn: 0, baseLoaders: 1, lockouts: ['grocersWarehouse'], copies: 3 },
{ kind: 'grocersWarehouse', name: "Grocer's Warehouse", carTypes: ['boxcar', 'reefer'], flow: 'inbound', baseOut: 0, baseIn: 1, baseLoaders: 1, lockouts: ['packingSheds', 'freightHouse'], copies: 3 },
];
/** Legacy alias; the engine still reads FREIGHT_PROFILES in places. */
@@ -381,6 +410,9 @@ export function crossingStages(
kind: MainlineKind,
trainSpeed: TrainSpeed,
carriesPassengers: boolean,
modifiers: readonly string[] = [],
direction: Direction = 'east',
gradeUp: Direction = 'east',
): number {
const profile = MAINLINE_PROFILES.find((m) => m.kind === kind);
if (!profile) throw new Error(`unknown mainline card: ${kind}`);
@@ -394,14 +426,65 @@ export function crossingStages(
mph = carriesPassengers ? profile.speed.passenger : profile.speed.freight;
break;
case 'grade':
// Heavy Grade has no printed number; the modifier cards (Brakeman/Airbrakes/Helpers) are what
// improve it. Treated as a 30 until those are implemented.
// Heavy Grade has no printed number; the modifier cards are what improve it, so it is a 30
// until one is placed.
mph = 30;
break;
}
const base = mph >= 60 ? 1 : 2;
return base + (trainSpeed === 'slow' ? 1 : 0);
const stages = base + (trainSpeed === 'slow' ? 1 : 0);
return Math.max(1, stages - gradeReduction(profile, modifiers, direction, gradeUp));
}
/**
* Q11, answered: the Heavy Grade card prints "(Up)" and "Player sets orientation", so which way it
* climbs is a property of the placed card, not a constant. `gradeUp` is the direction a train is
* travelling when it goes UPHILL; a train heading the other way is descending.
*
* Each applicable card takes a Stage off, never below one: a train cannot cross in no time.
* Airbrakes only counts when Brakeman is already there, which the placement rule enforces.
*/
function gradeReduction(
profile: MainlineProfile,
modifiers: readonly string[],
direction: Direction,
gradeUp: Direction,
): number {
if (profile.speed.kind !== 'grade') return 0;
const downhill = direction !== gradeUp;
let n = 0;
if (downhill) {
if (modifiers.includes('brakeman')) n++;
if (modifiers.includes('airbrakes')) n++;
} else if (modifiers.includes('helpers')) {
n++;
}
return n;
}
export function mainlineProfile(kind: MainlineKind): MainlineProfile {
const p = MAINLINE_PROFILES.find((m) => m.kind === kind);
if (!p) throw new Error(`unknown mainline card: ${kind}`);
return p;
}
/** Which Mainline modifiers may sit on `kind`, and what each additionally requires. */
export const MAINLINE_MODIFIER_RULES: readonly {
key: string;
/** Only placeable on a card whose speed is a grade. */
gradeOnly: boolean;
/** Another modifier that must already be on the same card. */
requiresOnCard?: string;
}[] = [
{ key: 'brakeman', gradeOnly: true },
{ key: 'airbrakes', gradeOnly: true, requiresOnCard: 'brakeman' },
{ key: 'helpers', gradeOnly: true },
{ key: 'realignment', gradeOnly: false },
];
export function mainlineModifierRule(key: string) {
return MAINLINE_MODIFIER_RULES.find((r) => r.key === key) ?? null;
}
/** `Realignment` converts one Mainline card into another. */
@@ -431,6 +514,45 @@ export const SPACE_USE_CARDS: readonly SimpleCard[] = [
{ key: 'engineerCemetery', name: 'Engineer cemetery', copies: 1, placement: 'adjacent to any straight, curve, turnout, Limit', effect: 'Burns tablespace.' },
];
export type EnhancementKey =
| 'interlocking' | 'facingPointLocks' | 'yardOffice' | 'smallYard'
| 'waterColumn' | 'overpass' | 'telegraph' | 'telephone' | 'radio' | 'absSignals';
/** Where an Enhancement may be laid. */
export type EnhancementPlacement =
| 'runningTrackStraight'
| 'secondaryTrackStraight'
| 'mainlineCard'
| 'onCard';
export type EnhancementRule = {
key: EnhancementKey;
placement: EnhancementPlacement;
/** Must sit on a card already carrying this enhancement (Telephone on Telegraph, etc.). */
requiresOnSameCard?: EnhancementKey;
/** Must exist somewhere in the district (Facing Point Locks needs Interlocking). */
requiresInDistrict?: EnhancementKey;
/** Bonus added to an opposing train's number when resolving a meet, once a Day. */
dispatchBonus?: number;
};
export const ENHANCEMENT_RULES: readonly EnhancementRule[] = [
{ key: 'interlocking', placement: 'runningTrackStraight' },
{ key: 'facingPointLocks', placement: 'onCard', requiresInDistrict: 'interlocking' },
{ key: 'yardOffice', placement: 'secondaryTrackStraight' },
{ key: 'smallYard', placement: 'secondaryTrackStraight' },
{ key: 'waterColumn', placement: 'runningTrackStraight' },
{ key: 'overpass', placement: 'onCard' },
{ key: 'telegraph', placement: 'runningTrackStraight', dispatchBonus: 4 },
{ key: 'telephone', placement: 'onCard', requiresOnSameCard: 'telegraph', dispatchBonus: 8 },
{ key: 'radio', placement: 'onCard', requiresOnSameCard: 'telephone', dispatchBonus: 12 },
{ key: 'absSignals', placement: 'mainlineCard' },
];
export function enhancementRule(key: string): EnhancementRule | null {
return ENHANCEMENT_RULES.find((r) => r.key === key) ?? null;
}
export const ENHANCEMENT_CARDS: readonly SimpleCard[] = [
{ key: 'interlocking', name: 'Interlocking', copies: 2, placement: 'any Running Track Straight', effect: 'May stop an inbound train on the Limit Track.' },
{ key: 'facingPointLocks', name: 'Facing Point Locks', copies: 2, placement: 'adjacent to Interlocking', effect: 'Must have Interlocking. Prevents Derail being played on you.' },
@@ -484,13 +606,21 @@ export const ACTION_CARDS: readonly SimpleCard[] = [
export type StockSupply = { type: CarType; loaded: number; empty: number };
/** NOT in the recovered files — still the provisional figure. */
/**
* NOT in the recovered files — still the provisional figure.
*
* Scaled up alongside the Gap 12 industry increase. Worst-case demand (every copy of every
* industry in play at full capacity) is boxcar 15, hopper 12, tank 9, reefer 6; the supply must
* cover that, since a Division Yard that runs dry starves the freight loop the increase exists to
* feed. Lockouts and district size mean the worst case cannot actually occur, so this carries
* deliberate headroom.
*/
export const ROLLING_STOCK_SUPPLY: readonly StockSupply[] = [
{ type: 'coach', loaded: 8, empty: 8 },
{ type: 'boxcar', loaded: 6, empty: 6 },
{ type: 'hopper', loaded: 6, empty: 6 },
{ type: 'reefer', loaded: 4, empty: 4 },
{ type: 'tank', loaded: 4, empty: 4 },
{ type: 'boxcar', loaded: 10, empty: 10 },
{ type: 'hopper', loaded: 8, empty: 8 },
{ type: 'reefer', loaded: 5, empty: 5 },
{ type: 'tank', loaded: 6, empty: 6 },
{ type: 'caboose', loaded: 6, empty: 0 },
];
@@ -537,7 +667,7 @@ export function lengthProfile(length: GameLength): LengthProfile {
}
// ---------------------------------------------------------------------------
// Deck composition — 115 cards
// Deck composition — 140 cards
// ---------------------------------------------------------------------------
/**
@@ -565,9 +695,9 @@ export function deckComposition(): { category: string; count: number }[] {
];
}
export const DECK_SIZE = deckComposition().reduce((n, c) => n + c.count, 0); // 115
export const DECK_SIZE = deckComposition().reduce((n, c) => n + c.count, 0); // 140
/** The solitaire deck drops the 22 opponent-directed cards, leaving 93. */
/** The solitaire deck drops the 22 opponent-directed cards, leaving 118. */
export const SOLITAIRE_DECK_SIZE = deckComposition()
.filter((c) => !isOpponentOnly(c.category))
.reduce((n, c) => n + c.count, 0);
+9 -1
View File
@@ -21,12 +21,17 @@ export type GameEvent =
| { type: 'actorChanged'; player: PlayerIndex | null }
// -- local operations
| { type: 'localOpsOptionChosen'; player: PlayerIndex; option: LocalOpsOption }
| { type: 'trayMoved'; trayId: TrayId; from: GridCoord; to: GridCoord; movesRemaining: number }
| { type: 'trayMoved'; trayId: TrayId; from: GridCoord; to: GridCoord; movesRemaining: number; facing?: 'n' | 's' | 'e' | 'w' }
| { type: 'carsCoupled'; trayId: TrayId; at: GridCoord; stock: RollingStock[] }
| { type: 'carsDropped'; trayId: TrayId; at: GridCoord; stock: RollingStock[] }
| { type: 'consistSorted'; trayId: TrayId; at: GridCoord; before: RollingStock[]; after: RollingStock[] }
| { type: 'cardDrawn'; player: PlayerIndex; source: 'homeOffice' | 'department'; slot?: number; cardId: CardId }
/** `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: 'trackLaid'; player: PlayerIndex; geometry: string; hand: string; at: GridCoord; variant: number; remaining: number }
| { type: 'mainlineModified'; player: PlayerIndex; cardId: CardId; node: number; key: string; became?: string }
| { type: 'redFlagsSet'; player: PlayerIndex; cardId: CardId; trayId: TrayId; node: number }
| { type: 'flyingSwitch'; player: PlayerIndex; cardId: CardId; trayId: TrayId; to: GridCoord; stock: RollingStock[] }
| { type: 'officeUpgraded'; player: PlayerIndex; from: OfficeTier; to: OfficeTier }
| { type: 'cardDiscarded'; player: PlayerIndex; cardId: CardId; toSlot: number }
| { type: 'deckReshuffled' }
@@ -46,15 +51,18 @@ export type GameEvent =
* which made the replay say "New Train" and nothing else. A train being made up, departing,
* arriving or finishing its run are four distinct facts and deserve four event types.
*/
| { type: 'enhancementPlaced'; player: PlayerIndex; key: string; at: GridCoord }
| { type: 'extraQueued'; player: PlayerIndex; trainNumber: number }
| { type: 'secondSectionOrdered'; player: PlayerIndex; trainNumber: number }
| { type: 'trainMadeUp'; trainNumber: number; isExtra: boolean; at: string; direction: string }
| { type: 'trainHeld'; trainNumber: number; reason: string }
| { type: 'trainHighballed'; trainNumber: number; from: string; to: string }
| { type: 'trainArrived'; trainNumber: number; consist: RollingStock[]; office: string }
| { type: 'trainDiverted'; trainNumber: number; to: string; reason: string }
| { type: 'trainCompleted'; trainNumber: number; consist: RollingStock[] }
| { type: 'carPlacedOnTrain'; player: PlayerIndex; trayId: TrayId; stock: RollingStock }
| { type: 'carPassed'; player: PlayerIndex; trayId: TrayId }
| { type: 'dispatchBonusUsed'; key: string; bonus: number; trainNumber: number; againstTrain: number }
| { type: 'clearanceRequested'; trainId: TrayId; occupiedBy: TrayId }
| { type: 'clearanceGiven'; trainId: TrayId; allow: boolean }
// -- load / unload
+33 -1
View File
@@ -4,7 +4,7 @@
* An intent is a PROPOSAL. It may be rejected. Contrast with an event (events.ts), which is a fact.
*/
import type { CarType, Direction, OfficeTier } from './content.ts';
import type { CarType, Direction, Hand, OfficeTier, TrackGeometry } from './content.ts';
import type { CardId, GridCoord, PlayerIndex, TrayId } from './state.ts';
// ---------------------------------------------------------------------------
@@ -18,6 +18,12 @@ export type Intent =
// -- switch (§6.1, Appendix A)
| { type: 'switch.move'; trayId: TrayId; to: GridCoord; reverse: boolean }
| { type: 'switch.dropCars'; trayId: TrayId; count: number }
/**
* Small Yard enhancement — "a train that spends one move in the yard may sort itself in any
* order, including cars in front of the engine". This is the designed answer to §A.3's
* come-off-in-seated-order constraint, which is what makes facing-point work possible.
*/
| { type: 'switch.sortConsist'; trayId: TrayId; order: number[] }
| { type: 'switch.end' }
// -- draw (§6.2)
| { type: 'draw.fromHomeOffice' }
@@ -25,6 +31,13 @@ export type Intent =
/** `variant` indexes `variantsFor(geometry)` — Gap 11: orientation is chosen on placement. */
| { type: 'card.play'; cardId: CardId; placement?: GridCoord; variant?: number }
| { type: 'card.discard'; cardId: CardId; toSlot: number }
/**
* Lay a piece from your personal track supply (§12.2 / content.ts TRACK_SUPPLY).
*
* Laid during the "draw a card" option, one piece a turn — how track behaved when it WAS a card.
* Confirmed; see implications.md §10 Q10.
*/
| { type: 'track.lay'; geometry: TrackGeometry; hand: Hand; placement: GridCoord; variant?: number }
| { type: 'draw.end' }
// -- freight agent (§6.3)
| { type: 'freightAgent.stockOutbound'; at: GridCoord; carType: CarType }
@@ -37,6 +50,21 @@ export type Intent =
| { type: 'newTrain.secondSection'; trainNumber: number }
// -- Mainline Phase (§8.1) — the Superintendent's clearance ruling
| { type: 'mainline.clearance'; allow: boolean }
/**
* Lay a Mainline modifier (Brakeman / Airbrakes / Helpers / Realignment) on a Mainline card.
* `node` indexes `division.nodes`.
*/
| { 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.
*/
| { type: 'maneuver.redFlags'; cardId: CardId; trayId: TrayId }
/**
* Flying Switch — cut cars off behind the engine and roll them into an adjacent industry, without
* the engine entering it.
*/
| { type: 'maneuver.flyingSwitch'; cardId: CardId; trayId: TrayId; count: number; to: GridCoord }
| { type: 'redFlag.play' }
// -- Load/Unload Phase (§9)
| { type: 'porter.board'; at: GridCoord }
@@ -83,6 +111,10 @@ export type RejectionCode =
| 'NOT_UPGRADEABLE'
| 'FACILITY_LOCKED'
| 'NOT_IMPLEMENTED'
| 'WRONG_INTENT'
| 'NOT_A_GRADE'
| 'TRAIN_ON_CARD'
| 'CONSIST_ORDER'
| 'NO_TRAIN_AT_OFFICE';
export type Rejection = { code: RejectionCode; message: string };
+64 -2
View File
@@ -12,7 +12,7 @@
* If you find yourself writing a rule here, it belongs in apply.ts.
*/
import type { CarType } from './content.ts';
import type { CarType, Hand, TrackGeometry } from './content.ts';
import { check, areaOf, destinationsFor } from './apply.ts';
import type { Intent } from './intents.ts';
import type { GameState, GridCoord, PlayerIndex } from './state.ts';
@@ -56,6 +56,14 @@ function candidates(s: GameState, player: PlayerIndex): Intent[] {
break;
}
// Red Flags — "any time", so they are candidates in every phase, on any train standing out on
// the Mainline (the player's own or another's: protecting a train is not an attack).
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 });
}
out.push({ type: 'redFlag.play' });
return out;
}
@@ -82,6 +90,34 @@ function localOpsCandidates(s: GameState, player: PlayerIndex): Intent[] {
for (let n = 1; n <= tray.consist.length; n++) {
out.push({ type: 'switch.dropCars', trayId, count: n });
}
// Small Yard: enumerating every permutation would explode, so offer the useful ones —
// bringing each car to the droppable end, plus a full reversal. `check` validates any order,
// so a UI may submit an arbitrary permutation.
const n = tray.consist.length;
if (n > 1) {
for (let k = 0; k < n; k++) {
const order = [...Array(n).keys()].filter((x) => x !== k);
order.push(k);
out.push({ type: 'switch.sortConsist', trayId, order });
}
out.push({ type: 'switch.sortConsist', trayId, order: [...Array(n).keys()].reverse() });
}
}
// Flying Switch — roll a cut into an ADJACENT industry without the engine entering it.
for (const cardId of s.decks.hands.get(player) ?? []) {
const k = s.cards.get(cardId)?.kind;
if (k?.kind !== 'maneuver' || k.key !== 'flyingSwitch') continue;
for (const [trayId, tray] of s.trays) {
if (tray.position.at !== 'grid' || tray.position.owner !== player) continue;
const from = tray.position.coord;
for (const reverse of [false, true]) {
for (const d of destinationsFor(s, player, trayId, from, reverse)) {
for (let count = 1; count <= tray.consist.length; count++) {
out.push({ type: 'maneuver.flyingSwitch', cardId, trayId, count, to: d.coord });
}
}
}
}
}
out.push({ type: 'switch.end' });
@@ -90,17 +126,43 @@ function localOpsCandidates(s: GameState, player: PlayerIndex): Intent[] {
for (let slot = 0; slot < 3; slot++) out.push({ type: 'draw.fromDepartment', slot });
const placements = placementCandidates(s, player);
// Enhancements ATTACH to a card already in the grid, so their candidates are the occupied cells,
// not the empty ones every other placeable card wants. Offering them `placements` meant an
// Enhancement was never once legal on a real card — 18 of 93 solitaire cards, permanently dead.
const attachments = [...area.grid.keys()].map((k) => {
const [row, col] = k.split(',').map(Number);
return { row: row!, col: col! };
});
for (const cardId of s.decks.hands.get(player) ?? []) {
out.push({ type: 'card.play', cardId });
// Gap 11 — orientation is chosen on placement, so every rotation is a distinct candidate.
// Six is the widest set (turnouts); `check` discards the ones whose ports do not meet.
for (const placement of placements) {
const targets = s.cards.get(cardId)?.kind.kind === 'enhancement' ? attachments : placements;
for (const placement of targets) {
for (let variant = 0; variant < 6; variant++) {
out.push({ type: 'card.play', cardId, placement, variant });
}
}
for (let slot = 0; slot < 3; slot++) out.push({ type: 'card.discard', cardId, toSlot: slot });
}
// Lay track from the personal supply — the only way a district grows now that track is not in
// the deck.
for (const [key, count] of s.turn.laidThisTurn ? [] : area.trackSupply) {
if (count < 1) continue;
const [geometry, hand] = key.split(':') as [TrackGeometry, Hand];
for (const placement of placements) {
for (let variant = 0; variant < 2; variant++) {
out.push({ type: 'track.lay', geometry, hand, placement, variant });
}
}
}
// Mainline modifiers go on a Mainline card, not a grid cell, so `node` indexes division.nodes.
for (const cardId of s.decks.hands.get(player) ?? []) {
if (s.cards.get(cardId)?.kind.kind !== 'mainlineModifier') continue;
for (let node = 0; node < s.division.nodes.length; node++) {
out.push({ type: 'mainline.modify', cardId, node });
}
}
out.push({ type: 'draw.end' });
// -- freight agent (§6.3)
+29 -6
View File
@@ -17,6 +17,8 @@ import {
MODIFIER_PROFILES,
OFFICE_PROFILES,
MAINLINE_PROFILES,
mainlineProfile,
TRACK_SUPPLY,
ROLLING_STOCK_SUPPLY,
STAGES_PER_DAY,
TIMETABLED_TRAINS,
@@ -122,6 +124,7 @@ function buildOfficeArea(owner: PlayerIndex): OfficeArea {
standing: [],
facility: buildPassengerFacility('whistlePost'),
modifiers: [],
enhancements: [],
};
const limitsCard = (): TrackCard => ({
@@ -130,6 +133,7 @@ function buildOfficeArea(owner: PlayerIndex): OfficeArea {
standing: [],
facility: null,
modifiers: [],
enhancements: [],
});
const grid = new Map<string, TrackCard>();
@@ -137,7 +141,19 @@ function buildOfficeArea(owner: PlayerIndex): OfficeArea {
grid.set(coordKey(limitsWest), limitsCard());
grid.set(coordKey(limitsEast), limitsCard());
return { owner, tier: 'whistlePost', grid, officeCoord, runningRow: row, limitsWest, limitsEast, adOccupancy: [] };
return {
owner,
tier: 'whistlePost',
grid,
officeCoord,
runningRow: row,
limitsWest,
limitsEast,
adOccupancy: [],
heldAtLimits: [],
dispatchUsedToday: [],
trackSupply: new Map(TRACK_SUPPLY.map((t) => [`${t.geometry}:${t.hand}`, t.perPlayer])),
};
}
/**
@@ -172,11 +188,18 @@ function buildPassengerFacility(tier: Parameters<typeof officeProfile>[0]): NonN
function buildDivision(players: number, rng: Rng): DivisionNode[] {
const nodes: DivisionNode[] = [];
const kinds = MAINLINE_PROFILES.map((m) => m.kind);
const mainline = (): DivisionNode => ({
kind: 'mainline',
card: kinds[rng.nextInt(kinds.length)]!,
transits: [],
});
const mainline = (): DivisionNode => {
const card = kinds[rng.nextInt(kinds.length)]!;
const node: DivisionNode = { kind: 'mainline', card, transits: [] };
// The Heavy Grade card says "Player sets orientation", but setup has no decision point yet —
// createGame is synchronous and returns a ready state. Rolled for now so the orientation is at
// least deterministic and varies between games; it should become a real player choice when
// setup gains an interactive phase. See implications.md §10 Q11.
if (mainlineProfile(card).speed.kind === 'grade') {
node.gradeUp = rng.nextInt(2) === 0 ? 'east' : 'west';
}
return node;
};
nodes.push({ kind: 'divisionPoint', side: 'west', holding: [] });
for (let p = 0; p < players; p++) {
+47 -2
View File
@@ -73,7 +73,9 @@ export type CardGeometry =
| { kind: 'limits' }
| { kind: 'facility'; facility: FreightKind; axis?: TrackAxis }
/** Not track — a Modifier sits beside a Facility and raises its capacity (§9). */
| { kind: 'modifier'; modifier: ModifierKind };
| { kind: 'modifier'; modifier: ModifierKind }
/** Not track — a Space-use card played at a district to consume a cell (Q6). */
| { kind: 'spaceUse'; key: string };
export type TrackCard = {
geometry: CardGeometry;
@@ -86,6 +88,8 @@ export type TrackCard = {
standing: RollingStock[];
facility: Facility | null;
modifiers: ModifierKind[];
/** Enhancement cards laid on this card (§7 of implications.md). */
enhancements: string[];
};
export type OfficeArea = {
@@ -99,6 +103,18 @@ export type OfficeArea = {
limitsEast: GridCoord;
/** Trays holding at the Office. Length must never exceed the tier's A/D track count. */
adOccupancy: TrayId[];
/**
* Trains held at the Limits by an Interlocking rather than admitted to the Office. They are
* inside the player's Limits but not occupying an A/D track.
*/
heldAtLimits: TrayId[];
/** Dispatch bonuses spent this Day, by enhancement key — each is once a Day. */
dispatchUsedToday: string[];
/**
* The player's personal track supply (26 pieces), keyed `geometry:hand`. Track is NOT in the
* Home Office deck — it is laid from here.
*/
trackSupply: Map<string, number>;
};
// ---------------------------------------------------------------------------
@@ -182,6 +198,15 @@ export type CrewTray = {
/** ORDERED, left-to-right. Max 4 including any caboose (§A.4). */
consist: RollingStock[];
direction: Direction;
/**
* Which way the engine points, as an actual port on the card beneath it.
*
* NOT derivable from `direction`, which only has east and west: a crew standing on a north-south
* spur points north or south, and deriving 'e'/'w' gave it an exit port the card does not have —
* so it had no legal moves at all and was stranded permanently. Left optional so a tray placed
* without one falls back to `direction`.
*/
facing?: 'n' | 's' | 'e' | 'w';
position: NodeRef;
movesUsed: number;
};
@@ -198,7 +223,21 @@ export type Transit = { tray: TrayId; stagesRemaining: number; direction: Direct
export type DivisionNode =
| { kind: 'divisionPoint'; side: Direction; holding: TrayId[] }
| { kind: 'mainline'; card: MainlineKind; transits: Transit[] }
| {
kind: 'mainline';
card: MainlineKind;
transits: Transit[];
absSignals?: boolean;
/** Brakeman / Airbrakes / Helpers / Realignment laid on this card. */
modifiers?: string[];
/**
* Which way a train is travelling when it climbs. The Heavy Grade card prints "(Up)" and
* "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'; owner: PlayerIndex };
/** Ordered west to east. For N players: N Office nodes and N+1 Mainline cards. */
@@ -328,6 +367,11 @@ export type TurnState = {
option: 'switch' | 'draw' | 'freightAgent' | null;
movesRemaining: number;
drawnThisTurn: boolean;
/**
* Track was a CARD before it became a per-player supply, so it was naturally limited to one play
* a turn. The supply has 26 pieces and nothing else bounds it, so keep that limit explicitly.
*/
laidThisTurn: boolean;
freightAgentUsed: boolean;
/** Set when the actor finishes; the phase driver then moves to the next player. */
done: boolean;
@@ -338,6 +382,7 @@ export function freshTurn(moves: number): TurnState {
option: null,
movesRemaining: moves,
drawnThisTurn: false,
laidThisTurn: false,
freightAgentUsed: false,
done: false,
};
+2 -1
View File
@@ -79,8 +79,9 @@ type PortPair = readonly [Port, Port];
*/
function connectionsFor(card: TrackCard): readonly PortPair[] {
switch (card.geometry.kind) {
// A Modifier is not track: nothing connects to it and no train may enter (§9).
// Neither a Modifier nor a Space-use card is track: nothing connects, no train may enter.
case 'modifier':
case 'spaceUse':
return [];
case 'limits':
return [['e', 'w']];
+329 -15
View File
@@ -25,7 +25,7 @@ import type { GameEvent } from '../engine/events.ts';
import type { Intent } from '../engine/intents.ts';
import { legalActions } from '../engine/legal.ts';
import { coordKey } from '../engine/state.ts';
import type { Facility, GameState, PlayerIndex, RollingStock } from '../engine/state.ts';
import type { Facility, GameState, GridCoord, PlayerIndex, RollingStock } from '../engine/state.ts';
export type BotPolicy = {
name: string;
@@ -63,20 +63,30 @@ export const developerBot: BotPolicy = {
const clearance = ruleOnClearance(options);
if (clearance) return clearance;
// 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 flags;
// --- Load/Unload: spend every worker, then end. Each is a point, or a step toward one.
//
// Order matters. A Porter earns a point in ONE action (§9.2), so it is always the best use of
// a worker. Then advance loads already in the pipeline — finishing beats starting, because a
// load parked on MEN|AT|WORK also locks the industry track (§9.3). Only then feed new work in.
if (s.clock.phase === 'loadUnload') {
const work = pickFirst(
options,
'porter.board',
'porter.detrain',
'laborer.advanceLoad',
'laborer.startLoad',
'laborer.beginUnload',
);
const work =
pickFirst(options, 'porter.board', 'porter.detrain', 'laborer.advanceLoad') ??
// Only START a load that can FINISH. A load leaves MEN|AT|WORK onto a spotted empty car of
// its own type (§9.3), so starting one with no car spotted parks it on WORK — which locks
// the industry track, which blocks the very car that would clear it. A self-inflicted
// deadlock, and the loop that was eating the game: 415 loads started, 413 unjams, and 13
// completions across 60 games.
options.find((i) => i.type === 'laborer.startLoad' && loadCanFinish(s, player, i.at)) ??
pickFirst(options, 'laborer.beginUnload');
// NO fallback to an unfinishable load. Idling a Laborer is strictly better than jamming: a
// jam costs a Freight Agent action to clear AND locks the industry track until it is cleared.
// A first attempt kept a last-resort `startLoad` here "rather than idle", and the measured
// result was identical to having no gate at all — 415 starts, 413 unjams, 13 completions.
return work ?? options.find((i) => i.type === 'loadUnload.end') ?? options[0]!;
}
@@ -127,8 +137,20 @@ function chooseLocalOption(s: GameState, player: PlayerIndex, options: Intent[])
// 1. Trains first, always. A scheduled train runs EVERY Day thereafter, so it is the only card
// whose value compounds. Playing one needs the draw option.
// An Office upgrade outranks even a train card: a train that arrives with nowhere to stand is a
// collision, and collisions are the largest single drain on revenue.
if (can('draw') && hand.some((id) => s.cards.get(id)?.kind.kind === 'office')) {
return can('draw')!;
}
if (can('draw') && hand.some((id) => isTrainCard(s, id))) return can('draw')!;
// A Mainline modifier is worth the option too: Realignment permanently converts a 30 card into a
// 60, and Helpers/Brakeman take a Stage off every future crossing. Like a train card, the value
// compounds — but only if it can be laid right now, so check for a legal target rather than for
// the card sitting in hand.
if (can('draw') && options.some((i) => i.type === 'mainline.modify')) return can('draw')!;
// 2. A train standing at the Office is a fleeting chance to spot cars — but ONLY if there is
// actually a car to spot or collect. Choosing to switch merely because a train is present
// wasted the whole Local Operations action shuttling back and forth: a passenger train needs
@@ -137,6 +159,12 @@ function chooseLocalOption(s: GameState, player: PlayerIndex, options: Intent[])
return can('switch')!;
}
// A crew stranded away from the Office is worth a whole turn on its own. A train may only
// highball from the Office square, so one sitting anywhere else is out of the game permanently —
// and the condition above never fires for it, because a crew down in the district has no work
// left and would never claim the option needed to walk back.
if (can('switch') && strandedFromOffice(s, player)) return can('switch')!;
// 3. Stock a green box only when the load can actually finish — an empty car of the right type
// is already spotted. Stocking without one just fills the box.
if (can('freightAgent') && canStockProductively(s, player)) return can('freightAgent')!;
@@ -163,6 +191,15 @@ function isWorthTaking(s: GameState, slot: number): boolean {
return false;
}
/** Does any facility want this particular car? Tolerates an undefined end car. */
function facilityWants2(s: GameState, player: PlayerIndex, car: RollingStock | undefined): boolean {
return car !== undefined && facilitiesWanting(s, player, car).length > 0;
}
function isEnhancement(s: GameState, cardId: string): boolean {
return s.cards.get(cardId)?.kind.kind === 'enhancement';
}
function isTrainCard(s: GameState, cardId: string): boolean {
const k = s.cards.get(cardId)?.kind.kind;
return k === 'timetabledTrain' || k === 'extraTrain';
@@ -213,6 +250,119 @@ function needsClearing(s: GameState, player: PlayerIndex): boolean {
return false;
}
/**
* Grow the district outward from the Office, preferring straights.
*
* Straights are what Enhancements attach to and what Freight Facilities sit beside, so they are
* worth more than a turnout the bot has no plan for. Ties break toward the Office: a compact
* district keeps Crew Moves cheap, and Moves are the real currency of Local Operations.
*/
function bestTrackLay(s: GameState, player: PlayerIndex, options: Intent[]): Intent | null {
const area = s.officeAreas.get(player);
if (!area) return null;
let best: Intent | null = null;
let bestScore = -Infinity;
for (const i of options) {
if (i.type !== 'track.lay') continue;
const dRow = Math.abs(i.placement.row - area.officeCoord.row);
const dCol = Math.abs(i.placement.col - area.officeCoord.col);
let score = -(dRow * 2 + dCol);
if (i.geometry === 'straight') score += 6;
// A district needs depth to hold facilities; a Running Track that only grows sideways gives
// Enhancements somewhere to live but Freight nowhere.
if (dRow > 0) score += 3;
if (score > bestScore) {
bestScore = score;
best = i;
}
}
return best;
}
function isEnhancementKey(s: GameState, cardId: string, key: string): boolean {
const k = s.cards.get(cardId)?.kind;
return k?.kind === 'enhancement' && k.key === key;
}
function isMainlineKey(s: GameState, cardId: string, key: string): boolean {
const k = s.cards.get(cardId)?.kind;
return k?.kind === 'mainlineModifier' && k.key === key;
}
/** Does the facility at `coord` want this particular car? */
function facilityWantsAt(
s: GameState,
player: PlayerIndex,
coord: { row: number; col: number },
car: { type: string; loaded: boolean } | undefined,
): boolean {
if (!car) return false;
const f = areaOf(s, player).grid.get(`${coord.row},${coord.col}`)?.facility;
return f ? facilityWants(f, car as never) : false;
}
/**
* 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.
*/
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;
}
/**
* Is there already an empty car of the right type spotted to receive this load (§9.3)? Without one
* the load can be started and walked across MEN|AT|WORK but can never come off.
*/
function loadCanFinish(s: GameState, player: PlayerIndex, at: GridCoord): boolean {
const f = areaOf(s, player).grid.get(`${at.row},${at.col}`)?.facility;
const next = f?.outboundBox[0];
if (!f || !next) return false;
return f.industryTrack.cars.some((c) => !c.loaded && c.type === next.type);
}
/** Is this player's crew sitting somewhere it can never depart from? */
function strandedFromOffice(s: GameState, player: PlayerIndex): boolean {
const area = areaOf(s, player);
const here = trayLocation(s, player);
if (!here) return false;
return here.row !== area.officeCoord.row || here.col !== area.officeCoord.col;
}
/**
* A Move that gets the crew strictly closer to the Office, or null if it is already there or
* nothing helps. "Strictly closer" is what stops this becoming the shuttling loop that an earlier
* version of the switching heuristic fell into — the bot cannot oscillate if every step reduces the
* distance.
*/
function moveTowardOffice(s: GameState, player: PlayerIndex, options: Intent[]): Intent | null {
const area = areaOf(s, player);
const here = trayLocation(s, player);
if (!here) return null;
const dist = (c: { row: number; col: number }): number =>
Math.abs(c.row - area.officeCoord.row) + Math.abs(c.col - area.officeCoord.col);
if (dist(here) === 0) return null;
let best: Intent | null = null;
let bestDist = dist(here);
for (const i of options) {
if (i.type !== 'switch.move') continue;
const d = dist(i.to);
if (d < bestDist) {
bestDist = d;
best = i;
}
}
return best;
}
/** Once an option is chosen, work it to a sensible conclusion. */
function followThrough(s: GameState, player: PlayerIndex, options: Intent[]): Intent {
switch (s.turn.option) {
@@ -232,12 +382,59 @@ function followThrough(s: GameState, player: PlayerIndex, options: Intent[]): In
const any = options.find((i) => i.type === 'draw.fromDepartment');
if (any) return any;
}
// Train cards first — they take no placement and their value compounds every Day.
// UPGRADE THE OFFICE FIRST. A Whistle Post has ONE A/D track, so a second arrival is an
// automatic collision (§8.3, Gap 2a) — and measured, 25 of 26 collisions happened at Whistle
// Post, none at all where an Interlocking was down. A Depot doubles the capacity and turns
// the Office into a Passenger Facility, which is where the porters and passenger revenue come
// from. The bot previously had no play preference for office cards at all, so an upgrade sat
// in hand behind trains, enhancements and track.
const upgrade = options.find(
(i) => i.type === 'card.play' && s.cards.get(i.cardId)?.kind.kind === 'office',
);
if (upgrade) return upgrade;
// Train cards next — they take no placement and their value compounds every Day.
const train = options.find(
(i) => i.type === 'card.play' && i.placement === undefined && isTrainCard(s, i.cardId),
);
if (train) return train;
// Mainline modifiers rank with train cards: every train that crosses afterwards pays the
// lower price. Realignment first — converting Curves to Plains halves the crossing for
// everyone, where a grade card only helps trains going one way.
const realign = options.find(
(i) => i.type === 'mainline.modify' && isMainlineKey(s, i.cardId, 'realignment'),
);
if (realign) return realign;
const grade = options.find((i) => i.type === 'mainline.modify');
if (grade) return grade;
// Enhancements next: Small Yard makes switching solvable, Interlocking stops the Office
// overflowing into a collision, and the dispatch devices win meets. All are worth more than
// another piece of plain track.
// Interlocking ahead of the other enhancements: it holds an arrival at the Limits instead of
// colliding into a full Office, and no collision was ever recorded in a district that had one.
const interlock = options.find(
(i) =>
i.type === 'card.play' &&
i.placement !== undefined &&
isEnhancementKey(s, i.cardId, 'interlocking'),
);
if (interlock) return interlock;
const enh = options.find(
(i) => i.type === 'card.play' && i.placement !== undefined && isEnhancement(s, i.cardId),
);
if (enh) return enh;
// Lay track before spending a card on the grid. Track is the scarce enabler, not the
// consolation prize: nothing else in the deck can create the Running-Track and Secondary-Track
// STRAIGHTS that Enhancements require, and no Freight Facility has anywhere to go until a
// district exists. Measured with track absent, the hand held a playable Enhancement on 4,778
// turns and could legally place one on 33.
const track = bestTrackLay(s, player, options);
if (track) return track;
// Then real development: a card actually laid into the grid.
const placed = options.find((i) => i.type === 'card.play' && i.placement !== undefined);
if (placed) return placed;
@@ -252,7 +449,25 @@ function followThrough(s: GameState, player: PlayerIndex, options: Intent[]): In
// Re-check every Move, not just when choosing the option. Conditions change mid-turn — a car
// gets coupled, a siding fills — and once there is nothing left to do the fall-through would
// pick an arbitrary legal move and burn the remaining Moves shuttling.
// Flying Switch is real work, so it must be weighed before concluding there is none left —
// otherwise the go-home branch below preempts it and the card never fires.
const flyingFirst = options.find(
(i) =>
i.type === 'maneuver.flyingSwitch' &&
i.count === 1 &&
facilityWantsAt(s, player, i.to, trayOf(s, player)?.consist.slice(-1)[0]),
);
if (flyingFirst) return flyingFirst;
if (!usefulSwitching(s, player)) {
// GO HOME. A train may only highball from the Office square itself (§8.1, Gap 2b) — from
// anywhere else `moveTrain` returns 'held', permanently. The bot used to end its turn
// wherever the work ran out, which stranded the crew: 77 of 93 trains still on the board at
// game end were sitting somewhere they could never depart from, 34 on the Running Track at
// the wrong column and 43 down in the district. A parked train earns nothing, holds its A/D
// track, and is unavailable for the next load.
const home = moveTowardOffice(s, player, options);
if (home) return home;
const stop = options.find((i) => i.type === 'switch.end');
if (stop) return stop;
}
@@ -272,15 +487,76 @@ function followThrough(s: GameState, player: PlayerIndex, options: Intent[]): In
const here = trayLocation(s, player);
const endCar = tray && tray.consist.length > 0 ? tray.consist[tray.consist.length - 1]! : null;
// If standing on a Small Yard, re-order so a car some facility actually wants ends up on
// the droppable end. This is the whole point of the card.
if (tray && here) {
const onYard = areaOf(s, player).grid.get(`${here.row},${here.col}`)?.enhancements.includes('smallYard');
if (onYard && !facilityWants2(s, player, tray.consist[tray.consist.length - 1])) {
const wantedIdx = tray.consist.findIndex((c) => facilitiesWanting(s, player, c).length > 0);
if (wantedIdx >= 0 && wantedIdx !== tray.consist.length - 1) {
const order = [...tray.consist.keys()].filter((k) => k !== wantedIdx);
order.push(wantedIdx);
const sort = options.find(
(i) => i.type === 'switch.sortConsist' && i.order.join() === order.join(),
);
if (sort) return sort;
}
}
}
if (endCar && here) {
const hereFacility = areaOf(s, player).grid.get(`${here.row},${here.col}`)?.facility ?? null;
const area = areaOf(s, player);
const hereCard = area.grid.get(`${here.row},${here.col}`);
const hereFacility = hereCard?.facility ?? null;
// Standing on a facility that wants the back car: put it down. This is the payoff move.
if (hereFacility && facilityWants(hereFacility, endCar)) {
const drop = options.find((i) => i.type === 'switch.dropCars' && i.count === 1);
if (drop) return drop;
}
// Otherwise head for a facility that does want this particular car.
const wanted = facilitiesWanting(s, player, endCar);
// SET OUT — and do it BEFORE travelling.
//
// Two cases. A crew at MAX_CONSIST cannot couple anything, because reachableDestinations
// drops any route whose pickups would overflow the tray, so it can never collect the loaded
// car it came for. And a crew whose back car is dead weight cannot deliver the wanted car
// hiding behind it, because §A.3 only lets the back car come off.
//
// Order matters. With travel first, the crew drove to a facility, found its end car
// undroppable, turned round for a spur, then drove back — the shuttling loop again, from a
// third direction. Fixing the consist first means every subsequent trip ends in a drop.
//
// A plain spur is the place to do it: dropping onto an industry track would silt it with the
// wrong commodity, and the Running Track has to stay clear.
const endCarUseless =
tray !== null &&
tray.consist.length > 1 &&
facilitiesWanting(s, player, endCar).length === 0 &&
tray.consist.some((c) => facilitiesWanting(s, player, c).length > 0);
if (tray && (tray.consist.length >= MAX_CONSIST || endCarUseless)) {
const isSpur = here.row !== area.runningRow && !hereFacility;
if (isSpur) {
const setOut = options.find((i) => i.type === 'switch.dropCars' && i.count === 1);
if (setOut) return setOut;
}
const toSpur = options.find(
(i) =>
i.type === 'switch.move' &&
i.to.row !== area.runningRow &&
!area.grid.get(`${i.to.row},${i.to.col}`)?.facility,
);
if (toSpur) return toSpur;
}
// Now travel — to a facility that wants SOMETHING aboard, not only the back car. Weighing
// the end car alone was the freight loop's real blocker: 744 switch turns produced 135 Moves
// and 741 immediate ends, because a crew holding wanted cars behind an unwanted one decided
// there was nothing to do. §A.3 makes the back car the only DROPPABLE one; it does not make
// it the only one worth travelling for.
const wanted = tray
? tray.consist.flatMap((c) => facilitiesWanting(s, player, c))
: facilitiesWanting(s, player, endCar);
const toward = options.find(
(i) =>
i.type === 'switch.move' &&
@@ -289,8 +565,13 @@ function followThrough(s: GameState, player: PlayerIndex, options: Intent[]): In
if (toward) return toward;
}
const move = options.find((i) => i.type === 'switch.move');
if (move) return move;
// Never take an arbitrary Move. Picking "the first legal move" is what produced the original
// shuttling bug, and it produced it again here: with the crew stranded but `usefulSwitching`
// still true, the go-home branch above is skipped and this fallback burned the remaining
// Moves oscillating between two cells. If there is no move with a purpose, the only useful
// thing left is to head for the Office, because a train that is not on it can never depart.
const goHome = moveTowardOffice(s, player, options);
if (goHome) return goHome;
return options.find((i) => i.type === 'switch.end') ?? options[0]!;
}
@@ -410,6 +691,26 @@ function usefulSwitching(s: GameState, player: PlayerIndex): boolean {
}
if (sidingsWorthCollecting(s, player).length > 0) return true;
// A FULL consist is itself work. At MAX_CONSIST the crew cannot couple anything, so every loaded
// car the district produces is unreachable until cars are set out. Without this clause the bot
// ended its turn the moment no facility wanted the end car, which is precisely when a full crew
// most needs to break itself up.
if (tray.consist.length >= MAX_CONSIST) {
for (const card of areaOf(s, player).grid.values()) {
if (card.facility && card.facility.kind === 'freight') return true;
}
}
// Standing on a Small Yard with a wanted car buried in the consist is work worth doing.
const here = trayLocation(s, player);
if (here) {
const card = areaOf(s, player).grid.get(`${here.row},${here.col}`);
if (card?.enhancements.includes('smallYard')) {
const buried = tray.consist.findIndex((c) => facilitiesWanting(s, player, c).length > 0);
if (buried >= 0 && buried !== tray.consist.length - 1) return true;
}
}
// A car left standing on ordinary track is also worth lifting if some facility wants it — the
// hopper stranded on the Running Track that the crew kept driving past.
if (tray.consist.length < MAX_CONSIST) {
@@ -514,11 +815,23 @@ export type PlayOutcome = {
* Drives a game to completion with the given policy. This is the whole reason the phase driver
* lives inside the engine: it is a plain loop over `advance` and `applyIntent`, with no server.
*/
/**
* Watches each event as it fires, with the state as it stands at that moment.
*
* This exists because the returned `events` log answers "what happened" but not "what was true when
* it happened" — the Office tier at a collision, which cards were in hand at a draw. Measuring those
* meant hand-rolling this loop in a throwaway script, and a hand-rolled copy that watched only the
* `pump` events (and not the ones from `applyIntent`) reported "60 of 60 games never upgraded" while
* the tier histogram plainly showed Depots and Stations. One driver, one place to observe.
*/
export type PlayObserver = (event: GameEvent, state: GameState) => void;
export function playGame(
s: GameState,
policy: BotPolicy,
pumpFn: (s: GameState) => GameEvent[],
maxTurns = 50_000,
observe?: PlayObserver,
): PlayOutcome {
const events: GameEvent[] = [];
const intents: Intent['type'][] = [];
@@ -530,6 +843,7 @@ export function playGame(
const tally = (batch: GameEvent[]): void => {
events.push(...batch);
for (const e of batch) {
observe?.(e, s);
if (e.type === 'trainScheduled') trainsScheduled++;
else if (e.type === 'cardPlayed') cardsPlayed++;
else if (
+46
View File
@@ -124,6 +124,12 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
where: e.at,
text: `Coupled ${e.stock.length} car(s) at ${at(e.at)}: ${carsLabel(e.stock)}`,
};
case 'consistSorted':
return {
tone: 'good',
where: e.at,
text: `SMALL YARD — consist re-ordered from [${carsLabel(e.before)}] to [${carsLabel(e.after)}], so the right car is now on the end and can be spotted`,
};
case 'carsDropped':
return {
tone: 'plain',
@@ -148,6 +154,30 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
? `Played ${card(e.cardId)} onto ${at(e.placement)}`
: `Played ${card(e.cardId)}`,
};
case 'mainlineModified':
return {
tone: 'plain',
text: e.became
? `Realignment: Mainline card ${e.node} converted to ${e.became}`
: `Played ${e.key} on Mainline card ${e.node}`,
};
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`,
};
case 'flyingSwitch':
return {
tone: 'good',
where: e.to,
text: `Flying Switch — ${e.stock.length} car(s) cut loose and rolled into the industry at ${at(e.to)} without the engine entering`,
};
case 'trackLaid':
return {
tone: 'plain',
where: e.at,
text: `Laid ${e.hand === 'none' ? '' : e.hand + '-hand '}${e.geometry} track at ${at(e.at)}${e.remaining} left in the supply`,
};
case 'officeUpgraded':
return { tone: 'good', text: `OFFICE UPGRADED — ${e.from}${e.to}` };
case 'cardDiscarded':
@@ -169,6 +199,12 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
tone: 'good',
text: `Extra X${e.trainNumber} played — it is NOT scheduled; it runs once as soon as a Crew Tray frees up, then its card is gone`,
};
case 'enhancementPlaced':
return {
tone: 'good',
where: e.at,
text: `ENHANCEMENT built: ${e.key.replace(/([A-Z])/g, ' $1')} at ${at(e.at)}`,
};
case 'secondSectionOrdered':
return {
tone: 'bad',
@@ -194,6 +230,11 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
tone: 'plain',
text: `Train ${e.trainNumber} ARRIVED at the ${e.office} carrying ${carsLabel(e.consist)} — it will highball again next Mainline Phase, so any work must happen now`,
};
case 'trainDiverted':
return {
tone: 'good',
text: `Train ${e.trainNumber} DIVERTED to ${e.to}${e.reason}`,
};
case 'trainCompleted':
return {
tone: 'quiet',
@@ -238,6 +279,11 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
return { tone: 'plain', text: `Added a ${carLabel(e.stock)} to the train being made up` };
case 'carPassed':
return { tone: 'quiet', text: 'Passed — no suitable car in the Division Yard' };
case 'dispatchBonusUsed':
return {
tone: 'good',
text: `${e.key.toUpperCase()} used (+${e.bonus}) — Train ${e.trainNumber} wins the meet against Train ${e.againstTrain}, which now counts as number ${e.againstTrain + e.bonus}. Once a Day only.`,
};
case 'clearanceRequested':
return {
tone: 'bad',
+1
View File
@@ -154,6 +154,7 @@ function snapshot(
else if (g.kind === 'limits') label = 'Limits';
else if (g.kind === 'facility') label = FACILITY_NAMES[g.facility] ?? g.facility;
else if (g.kind === 'modifier') label = MODIFIER_NAMES[g.modifier] ?? g.modifier;
else if (g.kind === 'spaceUse') label = prettyKey(g.key);
else label = g.geometry === 'sharpCurved' ? 'sharp curve' : g.geometry;
const fv = facilityView(card as never, officeProfile(area.tier).name);
+3
View File
@@ -220,6 +220,9 @@ const EXPECTED_EVENTS = [
'carPlacedOnTrain',
'trayMoved',
'carsCoupled',
'mainlineModified',
'redFlagsSet',
'flyingSwitch',
'carsDropped',
'cardDrawn',
'cardPlayed',
+31 -7
View File
@@ -9,7 +9,7 @@ import assert from 'node:assert/strict';
import { advance, pump } from '../src/engine/advance.ts';
import { applyIntent } from '../src/engine/apply.ts';
import { STAGES_PER_DAY, lengthProfile } from '../src/engine/content.ts';
import { STAGES_PER_DAY, lengthProfile, TOTAL_ROLLING_STOCK } from '../src/engine/content.ts';
import { legalActions } from '../src/engine/legal.ts';
import { createGame } from '../src/engine/setup.ts';
import type { GameConfig, GameState } from '../src/engine/state.ts';
@@ -294,7 +294,13 @@ describe('collisions are automatic (Gap 2)', () => {
movesUsed: 0,
});
const ml = s.division.nodes[1];
if (ml?.kind === 'mainline') ml.transits.push({ tray: id, stagesRemaining: 1, direction: 'east' });
if (ml?.kind === 'mainline') {
// Pin the terrain. Mainline types are drawn from the SHUFFLED deck, so deck composition
// would otherwise decide this test's outcome — and Double Track / Uncontrolled Siding set
// `trainsMayPass`, which legitimately removes the §8.1 bar being asserted here.
ml.card = 'plains';
ml.transits.push({ tray: id, stagesRemaining: 1, direction: 'east' });
}
s.clock.phase = 'mainline';
s.movedThisPhase = new Set();
@@ -311,7 +317,13 @@ describe('collisions are automatic (Gap 2)', () => {
const classBefore = s.yards.classificationYard.length;
const mlx = s.division.nodes[1];
if (mlx?.kind === 'mainline') mlx.transits.push({ tray: 'x', stagesRemaining: 1, direction: 'east' });
if (mlx?.kind === 'mainline') {
// Pin the terrain. Mainline types are drawn from the SHUFFLED deck, so deck composition
// would otherwise decide this test's outcome — and Double Track / Uncontrolled Siding set
// `trainsMayPass`, which legitimately removes the §8.1 bar being asserted here.
mlx.card = 'plains';
mlx.transits.push({ tray: 'x', stagesRemaining: 1, direction: 'east' });
}
s.trays.set('x', {
id: 'x',
trainNumber: 8,
@@ -354,7 +366,13 @@ describe('the Superintendent clearance interrupt (§8.1)', () => {
movesUsed: 0,
});
const ml = s.division.nodes[1];
if (ml?.kind === 'mainline') ml.transits.push({ tray: 'ahead', stagesRemaining: 2, direction: 'east' });
if (ml?.kind === 'mainline') {
// Pin the terrain. Mainline types are drawn from the SHUFFLED deck, so deck composition
// would otherwise decide this test's outcome — and Double Track / Uncontrolled Siding set
// `trainsMayPass`, which legitimately removes the §8.1 bar being asserted here.
ml.card = 'plains';
ml.transits.push({ tray: 'ahead', stagesRemaining: 2, direction: 'east' });
}
// A second train at the Western Division Point wanting to follow it.
s.trays.set('behind', {
@@ -391,7 +409,13 @@ describe('the Superintendent clearance interrupt (§8.1)', () => {
movesUsed: 0,
});
const ml = s.division.nodes[1];
if (ml?.kind === 'mainline') ml.transits.push({ tray: 'oncoming', stagesRemaining: 2, direction: 'west' });
if (ml?.kind === 'mainline') {
// Pin the terrain. Mainline types are drawn from the SHUFFLED deck, so deck composition
// would otherwise decide this test's outcome — and Double Track / Uncontrolled Siding set
// `trainsMayPass`, which legitimately removes the §8.1 bar being asserted here.
ml.card = 'plains';
ml.transits.push({ tray: 'oncoming', stagesRemaining: 2, direction: 'west' });
}
s.trays.set('waiting', {
id: 'waiting',
@@ -523,7 +547,7 @@ describe('MILESTONE: a full solitaire game runs headless', () => {
});
it('conserves rolling stock across a whole game', () => {
// Nothing may be created or destroyed: 62 pieces, wherever they sit.
// Nothing may be created or destroyed: every piece in the roster, wherever it sits.
const s = game(88);
playToCompletion(s, 88);
@@ -539,6 +563,6 @@ describe('MILESTONE: a full solitaire game runs headless', () => {
}
}
}
assert.equal(count, 62, 'rolling stock leaked or was duplicated');
assert.equal(count, TOTAL_ROLLING_STOCK, 'rolling stock leaked or was duplicated');
});
});
+4
View File
@@ -57,6 +57,7 @@ const straight = (standing: TrackCard['standing'] = []): TrackCard => ({
standing,
facility: null,
modifiers: [],
enhancements: [],
});
// ---------------------------------------------------------------------------
@@ -325,6 +326,7 @@ describe('Freight Agent operations (§6.3)', () => {
usedThisStage: { laborers: 0, porters: 0 },
},
modifiers: [],
enhancements: [],
});
return coord;
}
@@ -421,6 +423,7 @@ describe('Load/Unload: the four-action freight pipeline (§9.3)', () => {
usedThisStage: { laborers: 0, porters: 0 },
},
modifiers: [],
enhancements: [],
});
s.clock.phase = 'loadUnload';
return coord;
@@ -561,6 +564,7 @@ describe('legalActions shares its rules with apply (component 6)', () => {
usedThisStage: { laborers: 0, porters: 0 },
},
modifiers: [],
enhancements: [],
});
assert.deepEqual(chooseOptions(s), ['draw', 'freightAgent', 'switch']);
});
+362
View File
@@ -0,0 +1,362 @@
/**
* Enhancement cards (implications.md §7).
*
* These are the 18 cards that make up the largest block of the "third of the deck that does
* nothing". Several change core loops — Small Yard makes the facing-point switching puzzle
* solvable at all, and ABS Signals amends Gap 2's unconditional collisions.
*/
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { advance } from '../src/engine/advance.ts';
import { applyIntent, areaOf, check, hasDistrictEnhancement, isProtectedFromDerail } from '../src/engine/apply.ts';
import { ENHANCEMENT_RULES, enhancementRule } from '../src/engine/content.ts';
import { createGame } from '../src/engine/setup.ts';
import type { GameConfig, GameState, GridCoord, TrackCard } from '../src/engine/state.ts';
import { coordKey } from '../src/engine/state.ts';
const config: GameConfig = {
mode: 'solitaire',
victory: 'highestAfterDays',
length: 'standard',
optionalRules: { reducedVisibility: false, sisterTrains: false, employeeRotation: false, emergencyToolbox: false },
};
const game = (seed = 5): GameState => createGame({ id: 'g', seed, config, playerNames: ['p'] });
const at = (row: number, col: number): GridCoord => ({ row, col });
const straight = (): TrackCard => ({
geometry: { kind: 'track', geometry: 'straight' },
baseOperationalRail: true,
standing: [],
facility: null,
modifiers: [],
enhancements: [],
});
function addCard(s: GameState, coord: GridCoord, card: TrackCard): void {
areaOf(s, 0).grid.set(coordKey(coord), card);
}
/** Puts an enhancement card in hand and returns its id. */
function handEnhancement(s: GameState, key: string): string {
for (const [id, card] of s.cards) {
if (card.kind.kind === 'enhancement' && card.kind.key === key) {
s.decks.hands.set(0, [id]);
return id;
}
}
throw new Error(`no enhancement card: ${key}`);
}
function placeTray(s: GameState, coord: GridCoord, consist: TrackCard['standing'] = []): string {
const id = s.freeTrays.pop()!;
s.trays.set(id, {
id, trainNumber: 9, trainIsExtra: false, engineFront: true,
consist, direction: 'east', position: { at: 'grid', owner: 0, coord }, movesUsed: 0,
});
return id;
}
// ---------------------------------------------------------------------------
describe('enhancement placement', () => {
it('defines a rule for every enhancement card', () => {
assert.equal(ENHANCEMENT_RULES.length, 10);
for (const r of ENHANCEMENT_RULES) assert.ok(enhancementRule(r.key), r.key);
});
it('puts Interlocking on a Running Track straight, not a Secondary one', () => {
const s = game();
addCard(s, at(0, 2), straight());
addCard(s, at(-1, 0), straight());
const id = handEnhancement(s, 'interlocking');
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
assert.equal(check(s, 0, { type: 'card.play', cardId: id, placement: at(0, 2) }), null);
assert.equal(check(s, 0, { type: 'card.play', cardId: id, placement: at(-1, 0) }), 'NOT_CONNECTED');
});
it('puts Small Yard on a Secondary Track straight, not the Running Track', () => {
const s = game();
addCard(s, at(0, 2), straight());
addCard(s, at(-1, 0), straight());
const id = handEnhancement(s, 'smallYard');
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
assert.equal(check(s, 0, { type: 'card.play', cardId: id, placement: at(-1, 0) }), null);
assert.equal(check(s, 0, { type: 'card.play', cardId: id, placement: at(0, 2) }), 'NOT_CONNECTED');
});
it('stacks Telephone on Telegraph and Radio on Telephone, never bare', () => {
const s = game();
addCard(s, at(0, 2), straight());
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
const phone = handEnhancement(s, 'telephone');
assert.equal(check(s, 0, { type: 'card.play', cardId: phone, placement: at(0, 2) }), 'NOT_CONNECTED');
const tel = handEnhancement(s, 'telegraph');
assert.ok(applyIntent(s, 0, { type: 'card.play', cardId: tel, placement: at(0, 2) }).ok);
const phone2 = handEnhancement(s, 'telephone');
assert.equal(check(s, 0, { type: 'card.play', cardId: phone2, placement: at(0, 2) }), null);
});
it('requires an Interlocking in the district before Facing Point Locks', () => {
const s = game();
addCard(s, at(0, 2), straight());
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
const fpl = handEnhancement(s, 'facingPointLocks');
assert.equal(check(s, 0, { type: 'card.play', cardId: fpl, placement: at(0, 2) }), 'NOT_CONNECTED');
const lock = handEnhancement(s, 'interlocking');
applyIntent(s, 0, { type: 'card.play', cardId: lock, placement: at(0, 2) });
const fpl2 = handEnhancement(s, 'facingPointLocks');
assert.equal(check(s, 0, { type: 'card.play', cardId: fpl2, placement: at(0, 2) }), null);
assert.ok(isProtectedFromDerail(areaOf(s, 0)) === false, 'not protected until actually played');
});
});
// ---------------------------------------------------------------------------
describe('Small Yard — the card that makes switching solvable', () => {
function yardGame() {
const s = game();
const yard = at(-1, 0);
const card = straight();
card.enhancements.push('smallYard');
addCard(s, yard, card);
const tray = placeTray(s, yard, [
{ type: 'boxcar', loaded: false },
{ type: 'hopper', loaded: true },
{ type: 'coach', loaded: false },
]);
applyIntent(s, 0, { type: 'localOps.choose', option: 'switch' });
return { s, tray, yard };
}
it('re-orders a consist, including bringing a buried car to the end', () => {
// §A.3 forces cars off in seated order, so without this the middle car can never be spotted.
const { s, tray } = yardGame();
const r = applyIntent(s, 0, { type: 'switch.sortConsist', trayId: tray, order: [0, 2, 1] });
assert.ok(r.ok);
assert.deepEqual(
s.trays.get(tray)!.consist.map((c) => c.type),
['boxcar', 'coach', 'hopper'],
'the hopper is now on the droppable end',
);
});
it('costs one Move — "spends one move in the yard"', () => {
const { s, tray } = yardGame();
const before = s.turn.movesRemaining;
applyIntent(s, 0, { type: 'switch.sortConsist', trayId: tray, order: [2, 1, 0] });
assert.equal(s.turn.movesRemaining, before - 1);
});
it('is refused anywhere without a Small Yard', () => {
const s = game();
addCard(s, at(-1, 0), straight());
const tray = placeTray(s, at(-1, 0), [
{ type: 'boxcar', loaded: false },
{ type: 'hopper', loaded: true },
]);
applyIntent(s, 0, { type: 'localOps.choose', option: 'switch' });
assert.equal(
check(s, 0, { type: 'switch.sortConsist', trayId: tray, order: [1, 0] }),
'NOT_CONNECTED',
);
});
it('refuses an order that is not a permutation of the consist', () => {
const { s, tray } = yardGame();
for (const bad of [[0, 1], [0, 1, 1], [0, 1, 5]]) {
assert.equal(
check(s, 0, { type: 'switch.sortConsist', trayId: tray, order: bad }),
'CONSIST_ORDER',
`accepted ${JSON.stringify(bad)}`,
);
}
});
});
// ---------------------------------------------------------------------------
describe('Interlocking and Yard Office relieve the Office', () => {
function inbound(s: GameState, consist: TrackCard['standing']) {
const id = 'inbound';
s.trays.set(id, {
id, trainNumber: 9, trainIsExtra: false, engineFront: true,
consist, direction: 'east', position: { at: 'mainline', index: 1 }, movesUsed: 0,
});
const ml = s.division.nodes[1];
if (ml?.kind === 'mainline') {
// Pin the terrain. Mainline types are drawn from the SHUFFLED deck, so deck composition
// would otherwise decide this test's outcome — and Double Track / Uncontrolled Siding set
// `trainsMayPass`, which legitimately removes the §8.1 bar being asserted here.
ml.card = 'plains';
ml.transits.push({ tray: id, stagesRemaining: 1, direction: 'east' });
}
s.clock.phase = 'mainline';
s.movedThisPhase = new Set();
return id;
}
it('holds a train at the Limits instead of colliding when the Office is full', () => {
// Gap 2d made a full Office an automatic collision. Interlocking is the designed answer.
const s = game();
const card = straight();
card.enhancements.push('interlocking');
addCard(s, at(0, 2), card);
areaOf(s, 0).adOccupancy = ['blocker'];
const id = inbound(s, [{ type: 'hopper', loaded: true }]);
advance(s);
assert.equal(s.players[0]!.revenue, 0, 'no collision penalty');
assert.ok(areaOf(s, 0).heldAtLimits.includes(id), 'train is held at the Limits');
});
it('still collides without an Interlocking', () => {
const s = game();
areaOf(s, 0).adOccupancy = ['blocker'];
inbound(s, [{ type: 'hopper', loaded: true }]);
advance(s);
assert.equal(s.players[0]!.revenue, -5);
});
it('diverts a coachless train to the Yard Office', () => {
const s = game();
const card = straight();
card.enhancements.push('yardOffice');
addCard(s, at(-1, 0), card);
const id = inbound(s, [{ type: 'hopper', loaded: true }]);
advance(s);
const pos = s.trays.get(id)!.position;
assert.ok(pos.at === 'grid' && pos.coord.row === -1, 'arrived at the Yard Office');
assert.ok(!areaOf(s, 0).adOccupancy.includes(id), 'did not take an A/D track');
});
it('does not divert a train carrying coaches', () => {
// "An inbound train with NO COACHES" — passengers must reach the Train Order Office.
const s = game();
const card = straight();
card.enhancements.push('yardOffice');
addCard(s, at(-1, 0), card);
const id = inbound(s, [{ type: 'coach', loaded: true }]);
advance(s);
assert.ok(areaOf(s, 0).adOccupancy.includes(id), 'a coach train must use the Office');
});
});
// ---------------------------------------------------------------------------
describe('ABS Signals amend the collision rule', () => {
it('turns a following-train judgment into a plain hold', () => {
// "Trains on this card will not rear-end each other. They will stop short of a collision."
const s = game();
const ml = s.division.nodes[1];
if (ml?.kind === 'mainline') {
// Pin the terrain — see the meet tests below. Double Track and Uncontrolled Siding let trains
// pass, so the following train would never be held and there would be nothing for ABS to do.
ml.card = 'plains';
ml.absSignals = true;
ml.transits.push({ tray: 'ahead', stagesRemaining: 2, direction: 'east' });
}
s.trays.set('ahead', {
id: 'ahead', trainNumber: 4, trainIsExtra: false, engineFront: true,
consist: [], direction: 'east', position: { at: 'mainline', index: 1 }, movesUsed: 0,
});
s.trays.set('behind', {
id: 'behind', trainNumber: 2, trainIsExtra: false, engineFront: true,
consist: [], direction: 'east', position: { at: 'divisionPoint', side: 'west' }, movesUsed: 0,
});
s.clock.phase = 'mainline';
s.movedThisPhase = new Set();
const r = advance(s);
assert.equal(r.needsInput, false, 'no clearance decision is needed with signals');
assert.equal(s.clock.pendingDecision, null);
assert.deepEqual(s.trays.get('behind')!.position, { at: 'divisionPoint', side: 'west' });
});
});
// ---------------------------------------------------------------------------
describe('Telegraph, Telephone and Radio dispatch meets', () => {
function meet(s: GameState, device?: string) {
if (device) {
const card = straight();
card.enhancements.push(device);
addCard(s, at(0, 2), card);
}
// An oncoming senior train — §8.1 makes this an absolute bar without a device.
s.trays.set('oncoming', {
id: 'oncoming', trainNumber: 3, trainIsExtra: false, engineFront: true,
consist: [], direction: 'west', position: { at: 'mainline', index: 1 }, movesUsed: 0,
});
const ml = s.division.nodes[1];
if (ml?.kind === 'mainline') {
// Pin the terrain. Mainline types are drawn from the shuffled deck, so deck composition would
// otherwise decide this test's outcome — and Double Track / Uncontrolled Siding set
// `trainsMayPass`, which legitimately removes the §8.1 bar this test is about.
ml.card = 'plains';
ml.transits.push({ tray: 'oncoming', stagesRemaining: 2, direction: 'west' });
}
s.trays.set('mine', {
id: 'mine', trainNumber: 9, trainIsExtra: false, engineFront: true,
consist: [], direction: 'east', position: { at: 'divisionPoint', side: 'west' }, movesUsed: 0,
});
s.clock.phase = 'mainline';
s.movedThisPhase = new Set();
}
it('holds a facing train with no device — §8.1 stands', () => {
const s = game();
meet(s);
advance(s);
assert.deepEqual(s.trays.get('mine')!.position, { at: 'divisionPoint', side: 'west' });
});
it('lets a junior train win the meet with a Telegraph', () => {
// Train 9 against Train 3: +4 makes the other count as 7 — still senior. Radio's +12 wins.
const s = game();
meet(s, 'radio');
advance(s);
assert.equal(s.trays.get('mine')!.position.at, 'mainline', 'the meet was dispatched');
assert.ok(areaOf(s, 0).dispatchUsedToday.includes('radio'));
});
it('spends each device only once a Day', () => {
const s = game();
meet(s, 'radio');
advance(s);
assert.deepEqual(areaOf(s, 0).dispatchUsedToday, ['radio']);
// Reset happens at the Day boundary.
s.clock.stage = 12;
s.clock.phase = 'shiftChange';
advance(s);
assert.deepEqual(areaOf(s, 0).dispatchUsedToday, [], 'devices reset each Day');
});
it('gives Radio the largest bonus and Telegraph the smallest', () => {
assert.equal(enhancementRule('telegraph')!.dispatchBonus, 4);
assert.equal(enhancementRule('telephone')!.dispatchBonus, 8);
assert.equal(enhancementRule('radio')!.dispatchBonus, 12);
});
});
// ---------------------------------------------------------------------------
describe('defensive enhancements', () => {
it('reports district protection only once actually built', () => {
// These guard against opponent-directed cards, which a solitaire deck omits entirely (Q6).
const s = game();
assert.equal(isProtectedFromDerail(areaOf(s, 0)), false);
const card = straight();
card.enhancements.push('facingPointLocks');
addCard(s, at(0, 2), card);
assert.equal(isProtectedFromDerail(areaOf(s, 0)), true);
assert.equal(hasDistrictEnhancement(areaOf(s, 0), 'waterColumn'), false);
});
});
+414
View File
@@ -0,0 +1,414 @@
/**
* Mainline modifiers and Maneuver cards (implications.md §7).
*
* The 14 cards left after the Enhancements. These are NOT multiplayer-only: Brakeman, Airbrakes,
* Helpers and Realignment all change your own crossing times, and Red Flags prevents a rear-ender,
* which happens in solitaire too.
*
* Poling is deliberately absent — the recovered sheet records its effect as "TBD in the source", so
* there is nothing to implement and nothing to test.
*/
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { advance } from '../src/engine/advance.ts';
import { applyIntent, areaOf, check } from '../src/engine/apply.ts';
import {
MAINLINE_MODIFIER_CARDS,
MANEUVER_CARDS,
REALIGNMENTS,
crossingStages,
mainlineModifierRule,
} from '../src/engine/content.ts';
import { createGame } from '../src/engine/setup.ts';
import type { GameConfig, GameState, GridCoord, TrackCard } from '../src/engine/state.ts';
import { coordKey } from '../src/engine/state.ts';
const config: GameConfig = {
mode: 'solitaire',
victory: 'highestAfterDays',
length: 'standard',
optionalRules: { reducedVisibility: false, sisterTrains: false, employeeRotation: false, emergencyToolbox: false },
};
const game = (seed = 5): GameState => createGame({ id: 'g', seed, config, playerNames: ['p'] });
const at = (row: number, col: number): GridCoord => ({ row, col });
/** Puts a card of `kind`/`key` in hand and returns its id. */
function hand(s: GameState, kind: string, key: string): string {
for (const [id, card] of s.cards) {
const k = card.kind as { kind: string; key?: string };
if (k.kind === kind && k.key === key) {
s.decks.hands.set(0, [id]);
return id;
}
}
throw new Error(`no ${kind} card: ${key}`);
}
/** A Mainline node pinned to a known terrain, so deck composition cannot decide an outcome. */
function pinned(s: GameState, index: number, card: string) {
const node = s.division.nodes[index];
if (node?.kind !== 'mainline') throw new Error(`node ${index} is not a Mainline card`);
node.card = card as never;
node.transits = [];
return node;
}
/** Lets the player actually play a card this turn. */
function drawTurn(s: GameState): void {
s.clock.phase = 'localOps';
s.clock.currentActor = 0;
s.turn.option = 'draw';
s.turn.drawnThisTurn = true;
}
// ---------------------------------------------------------------------------
describe('grade modifiers change crossing time', () => {
it('a Heavy Grade takes two Stages bare', () => {
assert.equal(crossingStages('heavyGrade', 'fast', false), 2);
});
it('Brakeman speeds the descent but not the climb', () => {
// Q11 — the card prints "(Up)" and "Player sets orientation", so the last argument is which
// way is UPHILL. With up = east, a westbound train is descending.
assert.equal(crossingStages('heavyGrade', 'fast', false, ['brakeman'], 'west', 'east'), 1);
assert.equal(crossingStages('heavyGrade', 'fast', false, ['brakeman'], 'east', 'east'), 2);
});
it('follows the orientation the player chose, not a fixed compass direction', () => {
// The same train on the same card, with the card turned around: Brakeman helps a westbound
// train on an east-climbing grade, and an eastbound one when the grade climbs west.
assert.equal(crossingStages('heavyGrade', 'fast', false, ['brakeman'], 'east', 'west'), 1);
assert.equal(crossingStages('heavyGrade', 'fast', false, ['brakeman'], 'west', 'west'), 2);
assert.equal(crossingStages('heavyGrade', 'fast', false, ['helpers'], 'west', 'west'), 1);
assert.equal(crossingStages('heavyGrade', 'fast', false, ['helpers'], 'east', 'west'), 2);
});
it('Helpers speed the climb but not the descent', () => {
assert.equal(crossingStages('heavyGrade', 'fast', false, ['helpers'], 'east', 'east'), 1);
assert.equal(crossingStages('heavyGrade', 'fast', false, ['helpers'], 'west', 'east'), 2);
});
it('Airbrakes stack with Brakeman on a slow train', () => {
// A slow train pays 3 on a grade; Brakeman and Airbrakes take one Stage each.
assert.equal(crossingStages('heavyGrade', 'slow', false, [], 'west', 'east'), 3);
assert.equal(crossingStages('heavyGrade', 'slow', false, ['brakeman'], 'west', 'east'), 2);
assert.equal(
crossingStages('heavyGrade', 'slow', false, ['brakeman', 'airbrakes'], 'west', 'east'),
1,
);
});
it('never lets a train cross in no time', () => {
assert.equal(
crossingStages('heavyGrade', 'fast', false, ['brakeman', 'airbrakes'], 'west', 'east'),
1,
);
});
it('leaves non-grade cards alone', () => {
// Brakeman on Plains would be an illegal placement anyway; the maths must not move regardless.
assert.equal(crossingStages('plains', 'fast', false, ['brakeman'], 'west', 'east'), 1);
assert.equal(crossingStages('curves', 'fast', false, ['helpers'], 'east', 'east'), 2);
});
});
describe('placing a Mainline modifier', () => {
it('accepts Brakeman on a grade', () => {
const s = game();
pinned(s, 1, 'heavyGrade');
drawTurn(s);
const cardId = hand(s, 'mainlineModifier', 'brakeman');
assert.equal(check(s, 0, { type: 'mainline.modify', cardId, node: 1 }), null);
});
it('rejects Brakeman on anything but a grade', () => {
const s = game();
pinned(s, 1, 'plains');
drawTurn(s);
const cardId = hand(s, 'mainlineModifier', 'brakeman');
assert.equal(check(s, 0, { type: 'mainline.modify', cardId, node: 1 }), 'NOT_A_GRADE');
});
it('requires Brakeman before Airbrakes', () => {
const s = game();
const node = pinned(s, 1, 'heavyGrade');
drawTurn(s);
const cardId = hand(s, 'mainlineModifier', 'airbrakes');
assert.equal(check(s, 0, { type: 'mainline.modify', cardId, node: 1 }), 'NOT_CONNECTED');
node.modifiers = ['brakeman'];
assert.equal(check(s, 0, { type: 'mainline.modify', cardId, node: 1 }), null);
});
it('will not modify a card with a train on it', () => {
const s = game();
const node = pinned(s, 1, 'heavyGrade');
node.transits.push({ tray: 'someone', stagesRemaining: 1, direction: 'east' });
drawTurn(s);
const cardId = hand(s, 'mainlineModifier', 'brakeman');
assert.equal(check(s, 0, { type: 'mainline.modify', cardId, node: 1 }), 'TRAIN_ON_CARD');
});
it('rejects a Mainline node that is not a Mainline card', () => {
const s = game();
drawTurn(s);
const cardId = hand(s, 'mainlineModifier', 'brakeman');
// Node 0 is the western Division Point.
assert.equal(check(s, 0, { type: 'mainline.modify', cardId, node: 0 }), 'NO_PLACEMENT');
});
it('lays the modifier and spends the card', () => {
const s = game();
const node = pinned(s, 1, 'heavyGrade');
drawTurn(s);
const cardId = hand(s, 'mainlineModifier', 'brakeman');
const r = applyIntent(s, 0, { type: 'mainline.modify', cardId, node: 1 });
assert.ok(r.ok, 'placement should be accepted');
assert.deepEqual(node.modifiers, ['brakeman']);
assert.ok(!(s.decks.hands.get(0) ?? []).includes(cardId), 'card should leave the hand');
assert.ok(s.decks.salvageYard.includes(cardId), 'card should reach the Salvage Yard');
});
});
describe('Realignment converts one Mainline type to another', () => {
it('converts according to the table', () => {
const s = game();
const node = pinned(s, 1, 'curves');
drawTurn(s);
const cardId = hand(s, 'mainlineModifier', 'realignment');
const r = applyIntent(s, 0, { type: 'mainline.modify', cardId, node: 1 });
assert.ok(r.ok);
assert.equal(node.card, 'plains', 'Curves realigns to Plains');
// The point of the card: Curves is a 30 (two Stages), Plains a 60 (one).
assert.equal(crossingStages(node.card, 'fast', false), 1);
});
it('refuses a card with no conversion listed', () => {
const s = game();
// Tunnel is not a `from` in REALIGNMENTS.
assert.ok(!REALIGNMENTS.some((r) => r.from === 'tunnel'));
pinned(s, 1, 'tunnel');
drawTurn(s);
const cardId = hand(s, 'mainlineModifier', 'realignment');
assert.equal(check(s, 0, { type: 'mainline.modify', cardId, node: 1 }), 'NO_PLACEMENT');
});
it('applies to any Mainline card, not only grades', () => {
assert.equal(mainlineModifierRule('realignment')?.gradeOnly, false);
});
});
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, direction: 'east' });
s.trays.set('ahead', {
id: 'ahead', trainNumber: 4, trainIsExtra: false, engineFront: true,
consist: [], direction: 'east', position: { at: 'mainline', index: 1 }, movesUsed: 0,
});
s.trays.set('behind', {
id: 'behind', trainNumber: 2, trainIsExtra: false, engineFront: true,
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;
}
it('holds the approaching train instead of letting it close', () => {
const s = game();
const node = rearEnder(s);
node.redFlagged = ['ahead'];
advance(s);
assert.deepEqual(
s.trays.get('behind')!.position,
{ at: 'divisionPoint', side: 'west' },
'the flagged train must not be approached',
);
});
it('comes in when the protected train rolls', () => {
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;
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');
});
it('only protects a train out on the Mainline', () => {
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);
});
it('will not double-flag the same train', () => {
const s = game();
const node = rearEnder(s);
s.clock.phase = 'localOps';
s.clock.currentActor = 0;
const cardId = hand(s, 'maneuver', 'redFlags');
node.redFlagged = ['ahead'];
assert.equal(
check(s, 0, { type: 'maneuver.redFlags', cardId, trayId: 'ahead' }),
'OPTION_ALREADY_CHOSEN',
);
});
});
describe('Flying Switch rolls a cut into an industry', () => {
/** A crew on a straight with a facility next door, mid-switch. */
function setup(s: GameState) {
const area = areaOf(s, 0);
// A flying switch rolls the cut ALONG THE TRACK, so the spur and the industry must be joined
// north-south. Built east-west, they are two unconnected cards that merely look adjacent.
const spur: TrackCard = {
geometry: { kind: 'track', geometry: 'straight', axis: 'ns' },
baseOperationalRail: true,
standing: [],
facility: null,
modifiers: [],
enhancements: [],
};
area.grid.set(coordKey(at(-1, 0)), spur);
const industry: TrackCard = {
geometry: { kind: 'track', geometry: 'straight', axis: 'ns' },
baseOperationalRail: true,
standing: [],
facility: {
kind: 'freight', subtype: 'mineTipple',
allows: { outbound: true, inbound: false },
outboundBox: [], inboundBox: [],
capacity: { outbound: 1, inbound: 0 },
menAtWork: [null, null, null],
industryTrack: { length: 2, cars: [] },
laborers: 1, porters: 0,
usedThisStage: { laborers: 0, porters: 0 },
},
modifiers: [],
enhancements: [],
};
area.grid.set(coordKey(at(-2, 0)), industry);
s.trays.set('crew', {
id: 'crew', trainNumber: 1, trainIsExtra: false, engineFront: true,
consist: [{ type: 'hopper', loaded: false }, { type: 'boxcar', loaded: false }],
// Facing SOUTH, down the spur. `direction` only carries east/west, so a crew on north-south
// track needs its actual port — without it the engine looks for an east exit the card has not
// got, and the crew has no legal moves at all.
direction: 'east', facing: 's',
position: { at: 'grid', owner: 0, coord: at(-1, 0) }, movesUsed: 0,
});
s.clock.phase = 'localOps';
s.clock.currentActor = 0;
s.turn.option = 'switch';
s.turn.movesRemaining = 6;
return industry;
}
it('drops the cut onto the industry track without moving the engine', () => {
const s = game();
const industry = setup(s);
const cardId = hand(s, 'maneuver', 'flyingSwitch');
const r = applyIntent(s, 0, {
type: 'maneuver.flyingSwitch', cardId, trayId: 'crew', count: 1, to: at(-2, 0),
});
assert.ok(r.ok, 'the flying switch should be accepted');
assert.equal(industry.facility!.industryTrack.cars.length, 1, 'car reaches the industry track');
assert.equal(industry.facility!.industryTrack.cars[0]!.type, 'boxcar', '§A.3 — the back car');
assert.equal(s.trays.get('crew')!.consist.length, 1, 'the cut leaves the consist');
assert.deepEqual(
s.trays.get('crew')!.position,
{ at: 'grid', owner: 0, coord: at(-1, 0) },
'the engine never enters the industry',
);
assert.equal(s.turn.movesRemaining, 5, 'the manoeuvre costs a Move');
});
it('only reaches an adjacent card', () => {
const s = game();
setup(s);
const cardId = hand(s, 'maneuver', 'flyingSwitch');
assert.equal(
check(s, 0, { type: 'maneuver.flyingSwitch', cardId, trayId: 'crew', count: 1, to: at(-4, 0) }),
'NOT_CONNECTED',
);
});
it('rolls into an industry, not onto plain track', () => {
const s = game();
setup(s);
const cardId = hand(s, 'maneuver', 'flyingSwitch');
// (0,0) is the Office — track-connected to the crew, but not a freight industry.
assert.equal(
check(s, 0, { type: 'maneuver.flyingSwitch', cardId, trayId: 'crew', count: 1, to: at(0, 0) }),
'CANNOT_DROP_HERE',
);
});
it('will not roll onto a card with no track connection', () => {
const s = game();
setup(s);
const area = areaOf(s, 0);
// An industry sitting beside the crew, but joined east-west while the spur runs north-south.
const stranded = { ...area.grid.get(coordKey(at(-2, 0)))! };
stranded.geometry = { kind: 'track', geometry: 'straight', axis: 'ew' };
area.grid.set(coordKey(at(-1, 1)), stranded);
const cardId = hand(s, 'maneuver', 'flyingSwitch');
assert.equal(
check(s, 0, { type: 'maneuver.flyingSwitch', cardId, trayId: 'crew', count: 1, to: at(-1, 1) }),
'NOT_CONNECTED',
);
});
it('cannot cut more cars than it is hauling', () => {
const s = game();
setup(s);
const cardId = hand(s, 'maneuver', 'flyingSwitch');
assert.equal(
check(s, 0, { type: 'maneuver.flyingSwitch', cardId, trayId: 'crew', count: 5, to: at(-2, 0) }),
'CONSIST_EMPTY',
);
});
});
describe('card coverage', () => {
it('every Mainline modifier is either implemented or a known duplicate', () => {
for (const card of MAINLINE_MODIFIER_CARDS) {
const known =
mainlineModifierRule(card.key) !== null || card.key === 'facingPointLocksMainline';
assert.ok(known, `${card.key} has no rule and is not the Facing Point Locks duplicate`);
}
});
it('Poling remains unimplemented, deliberately', () => {
// Guard against someone "fixing" this by inventing an effect. The sheet says TBD; until the
// source says otherwise, a silent no-op would be worse than a rejection.
const poling = MANEUVER_CARDS.find((c) => c.key === 'poling');
assert.ok(poling, 'Poling should still be in the deck');
assert.match(poling!.effect, /TBD/i);
});
});
+34 -16
View File
@@ -49,17 +49,21 @@ const newSolitaireGame = (seed = 1234) =>
// ---------------------------------------------------------------------------
describe('card catalogue (component 1)', () => {
it('composes the 115-card deck from the design', () => {
// Transcribed from docs/Deck cards2.xlsx; the sheet's own total is 115.
assert.equal(DECK_SIZE, 115);
it('composes the 140-card deck from the design', () => {
// Transcribed from docs/Deck cards2.xlsx, whose own total is 115 — plus 18 extra industry cards
// (Gap 12, industries 9 → 27) and 7 extra office cards (Q12, offices 7 → 14). Both are
// deliberate departures from the sheet and both are flagged provisional in content.ts.
assert.equal(DECK_SIZE, 140);
assert.equal(buildDeck().length, DECK_SIZE);
});
it('matches the design deck composition exactly', () => {
const byCategory = Object.fromEntries(deckComposition().map((c) => [c.category, c.count]));
assert.deepEqual(byCategory, {
office: 7,
industry: 9,
// 14, not the sheet's 7 — Q12 office density; see OFFICE_PROFILES.
office: 14,
// 27, not the sheet's 9 — Gap 12 industry density; see INDUSTRY_PROFILES.
industry: 27,
modifier: 23,
train: 22,
spaceUse: 12,
@@ -72,13 +76,13 @@ describe('card catalogue (component 1)', () => {
it('removes opponent-directed cards from a solitaire deck', () => {
// Q6 — Space-use and Action cards can only be played AT another player, so in a one-player
// game they would be 22 of 115 draws (19%) that do nothing.
assert.equal(SOLITAIRE_DECK_SIZE, 93);
// game they would be 22 of 140 draws (16%) that do nothing.
assert.equal(SOLITAIRE_DECK_SIZE, 118);
const solo = buildDeck('solitaire');
assert.equal(solo.length, 93);
assert.equal(solo.length, 118);
assert.ok(!solo.some((c) => c.kind.kind === 'spaceUse' || c.kind.kind === 'action'));
// A competitive deck keeps them.
assert.equal(buildDeck('competitive').length, 115);
assert.equal(buildDeck('competitive').length, 140);
});
it('keeps track OUT of the deck, as a per-player supply', () => {
@@ -179,8 +183,20 @@ describe('card catalogue (component 1)', () => {
});
it('forms an office pyramid so the strict upgrade sequence cannot stall', () => {
// Assert the PROPERTY, not the literal counts. Upgrades are strictly sequential (Gap 3b), so
// every tier must be at least as common as the one above it — otherwise players reach a rung
// whose next card is scarcer than the one that got them there. Densities are provisional (Q12)
// and expected to move again; the pyramid shape is what must survive.
const inDeck = OFFICE_PROFILES.filter((o) => o.copiesInDeck > 0).map((o) => o.copiesInDeck);
assert.deepEqual(inDeck, [4, 2, 1]);
assert.ok(inDeck.length >= 2, 'there must be an upgrade ladder at all');
for (let i = 1; i < inDeck.length; i++) {
assert.ok(
inDeck[i]! <= inDeck[i - 1]!,
`tier ${i} has ${inDeck[i]} copies but the tier below it has ${inDeck[i - 1]}`,
);
}
// The Whistle Post is the starting state, never a card.
assert.equal(OFFICE_PROFILES.find((o) => o.tier === 'whistlePost')!.copiesInDeck, 0);
});
it('walks the upgrade sequence without skipping', () => {
@@ -190,9 +206,11 @@ describe('card catalogue (component 1)', () => {
assert.equal(nextOfficeTier('terminal'), null);
});
it('supplies 62 rolling stock pieces', () => {
assert.equal(TOTAL_ROLLING_STOCK, 62);
assert.equal(buildRollingStock().length, 62);
it('supplies the full rolling stock roster', () => {
// 80 pieces after the Gap 12 supply scale-up. Asserted against the constant rather than a
// literal so the invariant is "the yard holds exactly the roster", not a number to re-edit.
assert.equal(TOTAL_ROLLING_STOCK, 80);
assert.equal(buildRollingStock().length, TOTAL_ROLLING_STOCK);
});
it('never demands more cars than the supply can furnish', () => {
@@ -340,7 +358,7 @@ describe('game setup (component 2)', () => {
...g.decks.salvageYard,
...[...g.decks.hands.values()].flat(),
];
// A solitaire deck omits the 22 opponent-directed cards (Q6), so it holds 93, not 115.
// A solitaire deck omits the 22 opponent-directed cards (Q6), so it holds 118, not 140.
assert.equal(all.length, SOLITAIRE_DECK_SIZE, 'cards lost or duplicated');
assert.equal(new Set(all).size, SOLITAIRE_DECK_SIZE, 'duplicate card ids');
});
@@ -357,9 +375,9 @@ describe('game setup (component 2)', () => {
assert.equal(newSolitaireGame().freeTrays.length, 4);
});
it('puts all 62 rolling stock pieces in the Division Yard', () => {
it('puts every rolling stock piece in the Division Yard', () => {
const g = newSolitaireGame();
assert.equal(g.yards.divisionYard.length, 62);
assert.equal(g.yards.divisionYard.length, TOTAL_ROLLING_STOCK);
assert.equal(g.yards.classificationYard.length, 0);
});
+31
View File
@@ -287,3 +287,34 @@ describe('switching accomplishes something (regression)', () => {
assert.ok(moves < 60, `${moves.toFixed(0)} Moves per game suggests aimless shuttling`);
});
});
describe('measurement discipline', () => {
it('the observer sees exactly the events the log records', () => {
// REGRESSION, against a measurement bug rather than a game bug. Events reach the log from TWO
// sources — the phase driver (`pump`) and player intents (`applyIntent`). An ad-hoc probe that
// watched only the first reported "60 of 60 games never upgraded past Whistle Post" while the
// Office tier histogram from the same run plainly showed Depots, Stations and Terminals.
//
// A wrong measurement is worse than a missing one: it gets believed and acted on. This asserts
// the observer hook cannot drift from the log, so probes have one trustworthy way in and no
// reason to hand-roll the drive loop again.
const seen: string[] = [];
const s = createGame({ id: 'obs', seed: 4242, config: { ...config, length: 'standard' }, playerNames: ['b'] });
const out = playGame(s, developerBot, pump, 50_000, (e) => seen.push(e.type));
assert.ok(out.events.length > 0, 'a finished game must produce events');
assert.deepEqual(seen, out.events.map((e) => e.type));
});
it('records office upgrades where a probe can actually find them', () => {
// The upgrade arrives via applyIntent, not pump — the exact branch the broken probe missed.
// Asserted across several seeds because a single game may never draw a Depot card.
let upgrades = 0;
for (let seed = 0; seed < 25; seed++) {
const s = createGame({ id: `u${seed}`, seed, config: { ...config, length: 'standard' }, playerNames: ['b'] });
const out = playGame(s, developerBot, pump);
upgrades += out.events.filter((e) => e.type === 'officeUpgraded').length;
}
assert.ok(upgrades > 0, 'no office upgrade was visible in the event log across 25 games');
});
});
+9
View File
@@ -36,6 +36,7 @@ const straight = (standing: RollingStock[] = []): TrackCard => ({
standing,
facility: null,
modifiers: [],
enhancements: [],
});
/** A straight rotated to run north-south. Needed to meet the Office's junction stubs (Gap 11). */
@@ -45,6 +46,7 @@ const nsStraight = (standing: RollingStock[] = []): TrackCard => ({
standing,
facility: null,
modifiers: [],
enhancements: [],
});
const turnout = (o?: TurnoutOrientation): TrackCard => ({
@@ -55,6 +57,7 @@ const turnout = (o?: TurnoutOrientation): TrackCard => ({
standing: [],
facility: null,
modifiers: [],
enhancements: [],
});
const officeCard = (): TrackCard => ({
@@ -63,6 +66,7 @@ const officeCard = (): TrackCard => ({
standing: [],
facility: null,
modifiers: [],
enhancements: [],
});
const lockedFacility = (): TrackCard => ({
@@ -84,6 +88,7 @@ const lockedFacility = (): TrackCard => ({
usedThisStage: { laborers: 0, porters: 0 },
},
modifiers: [],
enhancements: [],
});
const car = (): RollingStock => ({ type: 'boxcar', loaded: false });
@@ -100,6 +105,9 @@ function areaFrom(cards: Record<string, TrackCard>, officeCoord: GridCoord): Off
limitsWest: { row: officeCoord.row, col: officeCoord.col - 2 },
limitsEast: { row: officeCoord.row, col: officeCoord.col + 2 },
adOccupancy: [],
heldAtLimits: [],
dispatchUsedToday: [],
trackSupply: new Map(),
};
}
@@ -159,6 +167,7 @@ describe('ports and geometry', () => {
standing: [],
facility: null,
modifiers: [],
enhancements: [],
};
assert.deepEqual(exitsFrom(r, 's'), ['w']);
});