Compare commits
2
Commits
401b879140
...
d261ad7af8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d261ad7af8 | ||
|
|
b085d5a0bb |
+241
-3
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 (
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -220,6 +220,9 @@ const EXPECTED_EVENTS = [
|
||||
'carPlacedOnTrain',
|
||||
'trayMoved',
|
||||
'carsCoupled',
|
||||
'mainlineModified',
|
||||
'redFlagsSet',
|
||||
'flyingSwitch',
|
||||
'carsDropped',
|
||||
'cardDrawn',
|
||||
'cardPlayed',
|
||||
|
||||
+31
-7
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user