Fix parking trains, freight deadlock, and Whistle Post lock-in
This commit is contained in:
+163
-8
@@ -76,14 +76,171 @@ Crossing time never falls below one Stage — a train cannot cross in no time.
|
|||||||
there is nothing to build. A test asserts it remains TBD, to stop anyone "fixing" it by inventing
|
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.
|
an effect; a silent no-op would be worse than a rejection.
|
||||||
|
|
||||||
> **Open question (Q11).** The sheet says Brakeman and Airbrakes give "faster passage downhill" and
|
**Q11, answered from the source.** The Heavy Grade card prints **"(Up)"** and **"Player sets
|
||||||
> Helpers "faster passage uphill", but never says **which way a Heavy Grade climbs**. Modelled as
|
orientation"**, so which way it climbs is a property of the placed card, not a fixed compass
|
||||||
> climbing **eastward** — eastbound is uphill, westbound downhill. If grades instead carry their own
|
direction. `DivisionNode.gradeUp` records the direction a train travels when **climbing**; a train
|
||||||
> direction, this becomes a property of the Mainline node rather than a constant.
|
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
|
**Still not implemented**: the Action (10) and Space-use (12) cards, which are genuinely
|
||||||
multiplayer-only. Playing them is rejected with `NOT_IMPLEMENTED`.
|
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)
|
### 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
|
Moving track out of the deck into a per-player supply (§12.2) left **no way to play it**. Districts
|
||||||
@@ -99,10 +256,8 @@ the grid.
|
|||||||
Fixed by adding a `track.lay` intent drawing from `OfficeArea.trackSupply`, and by giving
|
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.
|
Enhancements the occupied cells as candidates. Enhancement placements went 21 → 98 per 40 games.
|
||||||
|
|
||||||
> **Open question (Q10).** The design moves track to a per-player supply but never says *when* a
|
**Q10, answered.** Track is laid during the "draw a card" option, **one piece a turn** — which is
|
||||||
> piece may be laid. It is currently treated as a play available during the "draw a card" option,
|
how track behaved when it *was* a card.
|
||||||
> limited to **one piece a turn** — which is how track behaved when it *was* a card. This is an
|
|
||||||
> assumption, flagged in `intents.ts`, and needs confirming.
|
|
||||||
|
|
||||||
### Measurement after the answers
|
### Measurement after the answers
|
||||||
|
|
||||||
|
|||||||
@@ -356,6 +356,7 @@ function enterMainline(
|
|||||||
carriesPassengers,
|
carriesPassengers,
|
||||||
node.modifiers ?? [],
|
node.modifiers ?? [],
|
||||||
tray.direction,
|
tray.direction,
|
||||||
|
node.gradeUp ?? 'east',
|
||||||
);
|
);
|
||||||
node.transits.push({ tray: id, stagesRemaining: stages, direction: tray.direction });
|
node.transits.push({ tray: id, stagesRemaining: stages, direction: tray.direction });
|
||||||
tray.position = { at: 'mainline', index };
|
tray.position = { at: 'mainline', index };
|
||||||
|
|||||||
+25
-6
@@ -49,6 +49,7 @@ import {
|
|||||||
canDropCarsAt,
|
canDropCarsAt,
|
||||||
canPlaceAt,
|
canPlaceAt,
|
||||||
facilityVariants,
|
facilityVariants,
|
||||||
|
opposite,
|
||||||
reachableDestinations,
|
reachableDestinations,
|
||||||
variantsFor,
|
variantsFor,
|
||||||
} from './track.ts';
|
} from './track.ts';
|
||||||
@@ -103,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. */
|
/** A tray's facing, expressed as the port it would leave by going forward. */
|
||||||
function facingPort(s: GameState, trayId: TrayId): Port {
|
function facingPort(s: GameState, trayId: TrayId): Port {
|
||||||
const tray = s.trays.get(trayId);
|
const tray = s.trays.get(trayId);
|
||||||
|
if (tray?.facing) return tray.facing;
|
||||||
return tray?.direction === 'west' ? 'w' : 'e';
|
return tray?.direction === 'west' ? 'w' : 'e';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -392,11 +394,19 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
|
|||||||
const here = trayCoord(s, i.trayId);
|
const here = trayCoord(s, i.trayId);
|
||||||
if (!here) return 'CANNOT_DROP_HERE';
|
if (!here) return 'CANNOT_DROP_HERE';
|
||||||
if (i.count < 1 || i.count > tray.consist.length) return 'CONSIST_EMPTY';
|
if (i.count < 1 || i.count > tray.consist.length) return 'CONSIST_EMPTY';
|
||||||
// The cut rolls into an ADJACENT industry — the engine never enters, which is the whole point
|
// The cut is uncoupled and ROLLS to the industry under its own momentum, so the target must
|
||||||
// of the move and why it beats a normal spot.
|
// be somewhere the train could itself have run to — track-connected, not merely a neighbouring
|
||||||
const adjacent =
|
// square. An earlier version tested orthogonal adjacency, which was wrong in both directions:
|
||||||
Math.abs(i.to.row - here.row) + Math.abs(i.to.col - here.col) === 1;
|
// it would have allowed a cut to cross to a cell with no rail between, while refusing a siding
|
||||||
if (!adjacent) return 'NOT_CONNECTED';
|
// 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 fsArea = areaOf(s, player);
|
||||||
const target = fsArea.grid.get(coordKey(i.to));
|
const target = fsArea.grid.get(coordKey(i.to));
|
||||||
if (!target?.facility || target.facility.kind !== 'freight') return 'CANNOT_DROP_HERE';
|
if (!target?.facility || target.facility.kind !== 'freight') return 'CANNOT_DROP_HERE';
|
||||||
@@ -618,7 +628,11 @@ function destinationsFor(
|
|||||||
) {
|
) {
|
||||||
const tray = s.trays.get(trayId)!;
|
const tray = s.trays.get(trayId)!;
|
||||||
const facing = facingPort(s, 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(
|
return reachableDestinations(
|
||||||
{
|
{
|
||||||
area: areaOf(s, player),
|
area: areaOf(s, player),
|
||||||
@@ -644,6 +658,9 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
|
|||||||
const from = trayCoord(s, i.trayId)!;
|
const from = trayCoord(s, i.trayId)!;
|
||||||
const dests = destinationsFor(s, player, i.trayId, from, i.reverse);
|
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)!;
|
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[] = [
|
const events: GameEvent[] = [
|
||||||
{
|
{
|
||||||
type: 'trayMoved',
|
type: 'trayMoved',
|
||||||
@@ -651,6 +668,7 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
|
|||||||
from,
|
from,
|
||||||
to: i.to,
|
to: i.to,
|
||||||
movesRemaining: s.turn.movesRemaining - 1,
|
movesRemaining: s.turn.movesRemaining - 1,
|
||||||
|
facing: opposite(dest.entry),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
if (dest.couples.length > 0) {
|
if (dest.couples.length > 0) {
|
||||||
@@ -941,6 +959,7 @@ export function reduce(s: GameState, e: GameEvent): void {
|
|||||||
const tray = s.trays.get(e.trayId)!;
|
const tray = s.trays.get(e.trayId)!;
|
||||||
const owner = tray.position.at === 'grid' ? tray.position.owner : 0;
|
const owner = tray.position.at === 'grid' ? tray.position.owner : 0;
|
||||||
tray.position = { at: 'grid', owner, coord: e.to };
|
tray.position = { at: 'grid', owner, coord: e.to };
|
||||||
|
if (e.facing) tray.facing = e.facing;
|
||||||
s.turn.movesRemaining = e.movesRemaining;
|
s.turn.movesRemaining = e.movesRemaining;
|
||||||
|
|
||||||
// An A/D track is held only while the train is actually standing at the Office (§2.1).
|
// An A/D track is held only while the train is actually standing at the Office (§2.1).
|
||||||
|
|||||||
+32
-13
@@ -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
|
* 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).
|
* 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[] = [
|
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: '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: '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: 2 },
|
{ 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: 1 },
|
{ 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'];
|
export const OFFICE_ORDER: readonly OfficeTier[] = ['whistlePost', 'depot', 'station', 'terminal'];
|
||||||
@@ -393,6 +412,7 @@ export function crossingStages(
|
|||||||
carriesPassengers: boolean,
|
carriesPassengers: boolean,
|
||||||
modifiers: readonly string[] = [],
|
modifiers: readonly string[] = [],
|
||||||
direction: Direction = 'east',
|
direction: Direction = 'east',
|
||||||
|
gradeUp: Direction = 'east',
|
||||||
): number {
|
): number {
|
||||||
const profile = MAINLINE_PROFILES.find((m) => m.kind === kind);
|
const profile = MAINLINE_PROFILES.find((m) => m.kind === kind);
|
||||||
if (!profile) throw new Error(`unknown mainline card: ${kind}`);
|
if (!profile) throw new Error(`unknown mainline card: ${kind}`);
|
||||||
@@ -414,15 +434,13 @@ export function crossingStages(
|
|||||||
|
|
||||||
const base = mph >= 60 ? 1 : 2;
|
const base = mph >= 60 ? 1 : 2;
|
||||||
const stages = base + (trainSpeed === 'slow' ? 1 : 0);
|
const stages = base + (trainSpeed === 'slow' ? 1 : 0);
|
||||||
return Math.max(1, stages - gradeReduction(profile, modifiers, direction));
|
return Math.max(1, stages - gradeReduction(profile, modifiers, direction, gradeUp));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ASSUMPTION, flagged (§10 Q11): the recovered sheet says Brakeman and Airbrakes give "faster
|
* Q11, answered: the Heavy Grade card prints "(Up)" and "Player sets orientation", so which way it
|
||||||
* passage downhill" and Helpers "faster passage uphill", but never says which way a Heavy Grade
|
* climbs is a property of the placed card, not a constant. `gradeUp` is the direction a train is
|
||||||
* climbs. Modelled as **climbing eastward** — an eastbound train is going uphill, a westbound one
|
* travelling when it goes UPHILL; a train heading the other way is descending.
|
||||||
* downhill. Needs confirming; if grades instead carry their own direction, this becomes a property
|
|
||||||
* of the Mainline node rather than a constant.
|
|
||||||
*
|
*
|
||||||
* Each applicable card takes a Stage off, never below one: a train cannot cross in no time.
|
* 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.
|
* Airbrakes only counts when Brakeman is already there, which the placement rule enforces.
|
||||||
@@ -431,9 +449,10 @@ function gradeReduction(
|
|||||||
profile: MainlineProfile,
|
profile: MainlineProfile,
|
||||||
modifiers: readonly string[],
|
modifiers: readonly string[],
|
||||||
direction: Direction,
|
direction: Direction,
|
||||||
|
gradeUp: Direction,
|
||||||
): number {
|
): number {
|
||||||
if (profile.speed.kind !== 'grade') return 0;
|
if (profile.speed.kind !== 'grade') return 0;
|
||||||
const downhill = direction === 'west';
|
const downhill = direction !== gradeUp;
|
||||||
let n = 0;
|
let n = 0;
|
||||||
if (downhill) {
|
if (downhill) {
|
||||||
if (modifiers.includes('brakeman')) n++;
|
if (modifiers.includes('brakeman')) n++;
|
||||||
@@ -648,7 +667,7 @@ export function lengthProfile(length: GameLength): LengthProfile {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Deck composition — 133 cards
|
// Deck composition — 140 cards
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -676,9 +695,9 @@ export function deckComposition(): { category: string; count: number }[] {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DECK_SIZE = deckComposition().reduce((n, c) => n + c.count, 0); // 133
|
export const DECK_SIZE = deckComposition().reduce((n, c) => n + c.count, 0); // 140
|
||||||
|
|
||||||
/** The solitaire deck drops the 22 opponent-directed cards, leaving 111. */
|
/** The solitaire deck drops the 22 opponent-directed cards, leaving 118. */
|
||||||
export const SOLITAIRE_DECK_SIZE = deckComposition()
|
export const SOLITAIRE_DECK_SIZE = deckComposition()
|
||||||
.filter((c) => !isOpponentOnly(c.category))
|
.filter((c) => !isOpponentOnly(c.category))
|
||||||
.reduce((n, c) => n + c.count, 0);
|
.reduce((n, c) => n + c.count, 0);
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export type GameEvent =
|
|||||||
| { type: 'actorChanged'; player: PlayerIndex | null }
|
| { type: 'actorChanged'; player: PlayerIndex | null }
|
||||||
// -- local operations
|
// -- local operations
|
||||||
| { type: 'localOpsOptionChosen'; player: PlayerIndex; option: LocalOpsOption }
|
| { 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: 'carsCoupled'; trayId: TrayId; at: GridCoord; stock: RollingStock[] }
|
||||||
| { type: 'carsDropped'; 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: 'consistSorted'; trayId: TrayId; at: GridCoord; before: RollingStock[]; after: RollingStock[] }
|
||||||
|
|||||||
@@ -34,9 +34,8 @@ export type Intent =
|
|||||||
/**
|
/**
|
||||||
* Lay a piece from your personal track supply (§12.2 / content.ts TRACK_SUPPLY).
|
* Lay a piece from your personal track supply (§12.2 / content.ts TRACK_SUPPLY).
|
||||||
*
|
*
|
||||||
* ASSUMPTION, flagged: the design moves track out of the deck into a per-player supply but does
|
* Laid during the "draw a card" option, one piece a turn — how track behaved when it WAS a card.
|
||||||
* not say when you may lay it. Treated as a play available during the "draw a card" option,
|
* Confirmed; see implications.md §10 Q10.
|
||||||
* matching how track behaved when it WAS a card. See implications.md §10 Q10.
|
|
||||||
*/
|
*/
|
||||||
| { type: 'track.lay'; geometry: TrackGeometry; hand: Hand; placement: GridCoord; variant?: number }
|
| { type: 'track.lay'; geometry: TrackGeometry; hand: Hand; placement: GridCoord; variant?: number }
|
||||||
| { type: 'draw.end' }
|
| { type: 'draw.end' }
|
||||||
|
|||||||
+6
-10
@@ -109,16 +109,12 @@ function localOpsCandidates(s: GameState, player: PlayerIndex): Intent[] {
|
|||||||
if (k?.kind !== 'maneuver' || k.key !== 'flyingSwitch') continue;
|
if (k?.kind !== 'maneuver' || k.key !== 'flyingSwitch') continue;
|
||||||
for (const [trayId, tray] of s.trays) {
|
for (const [trayId, tray] of s.trays) {
|
||||||
if (tray.position.at !== 'grid' || tray.position.owner !== player) continue;
|
if (tray.position.at !== 'grid' || tray.position.owner !== player) continue;
|
||||||
const { row, col } = tray.position.coord;
|
const from = tray.position.coord;
|
||||||
const around: GridCoord[] = [
|
for (const reverse of [false, true]) {
|
||||||
{ row: row + 1, col },
|
for (const d of destinationsFor(s, player, trayId, from, reverse)) {
|
||||||
{ row: row - 1, col },
|
for (let count = 1; count <= tray.consist.length; count++) {
|
||||||
{ row, col: col + 1 },
|
out.push({ type: 'maneuver.flyingSwitch', cardId, trayId, count, to: d.coord });
|
||||||
{ row, col: col - 1 },
|
}
|
||||||
];
|
|
||||||
for (const to of around) {
|
|
||||||
for (let count = 1; count <= tray.consist.length; count++) {
|
|
||||||
out.push({ type: 'maneuver.flyingSwitch', cardId, trayId, count, to });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-5
@@ -17,6 +17,7 @@ import {
|
|||||||
MODIFIER_PROFILES,
|
MODIFIER_PROFILES,
|
||||||
OFFICE_PROFILES,
|
OFFICE_PROFILES,
|
||||||
MAINLINE_PROFILES,
|
MAINLINE_PROFILES,
|
||||||
|
mainlineProfile,
|
||||||
TRACK_SUPPLY,
|
TRACK_SUPPLY,
|
||||||
ROLLING_STOCK_SUPPLY,
|
ROLLING_STOCK_SUPPLY,
|
||||||
STAGES_PER_DAY,
|
STAGES_PER_DAY,
|
||||||
@@ -187,11 +188,18 @@ function buildPassengerFacility(tier: Parameters<typeof officeProfile>[0]): NonN
|
|||||||
function buildDivision(players: number, rng: Rng): DivisionNode[] {
|
function buildDivision(players: number, rng: Rng): DivisionNode[] {
|
||||||
const nodes: DivisionNode[] = [];
|
const nodes: DivisionNode[] = [];
|
||||||
const kinds = MAINLINE_PROFILES.map((m) => m.kind);
|
const kinds = MAINLINE_PROFILES.map((m) => m.kind);
|
||||||
const mainline = (): DivisionNode => ({
|
const mainline = (): DivisionNode => {
|
||||||
kind: 'mainline',
|
const card = kinds[rng.nextInt(kinds.length)]!;
|
||||||
card: kinds[rng.nextInt(kinds.length)]!,
|
const node: DivisionNode = { kind: 'mainline', card, transits: [] };
|
||||||
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: [] });
|
nodes.push({ kind: 'divisionPoint', side: 'west', holding: [] });
|
||||||
for (let p = 0; p < players; p++) {
|
for (let p = 0; p < players; p++) {
|
||||||
|
|||||||
@@ -198,6 +198,15 @@ export type CrewTray = {
|
|||||||
/** ORDERED, left-to-right. Max 4 including any caboose (§A.4). */
|
/** ORDERED, left-to-right. Max 4 including any caboose (§A.4). */
|
||||||
consist: RollingStock[];
|
consist: RollingStock[];
|
||||||
direction: Direction;
|
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;
|
position: NodeRef;
|
||||||
movesUsed: number;
|
movesUsed: number;
|
||||||
};
|
};
|
||||||
@@ -221,6 +230,11 @@ export type DivisionNode =
|
|||||||
absSignals?: boolean;
|
absSignals?: boolean;
|
||||||
/** Brakeman / Airbrakes / Helpers / Realignment laid on this card. */
|
/** Brakeman / Airbrakes / Helpers / Realignment laid on this card. */
|
||||||
modifiers?: string[];
|
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. */
|
/** Red Flags protecting a stopped train here, by tray. */
|
||||||
redFlagged?: TrayId[];
|
redFlagged?: TrayId[];
|
||||||
}
|
}
|
||||||
|
|||||||
+177
-44
@@ -25,7 +25,7 @@ import type { GameEvent } from '../engine/events.ts';
|
|||||||
import type { Intent } from '../engine/intents.ts';
|
import type { Intent } from '../engine/intents.ts';
|
||||||
import { legalActions } from '../engine/legal.ts';
|
import { legalActions } from '../engine/legal.ts';
|
||||||
import { coordKey } from '../engine/state.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 = {
|
export type BotPolicy = {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -74,14 +74,19 @@ export const developerBot: BotPolicy = {
|
|||||||
// a worker. Then advance loads already in the pipeline — finishing beats starting, because a
|
// 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.
|
// load parked on MEN|AT|WORK also locks the industry track (§9.3). Only then feed new work in.
|
||||||
if (s.clock.phase === 'loadUnload') {
|
if (s.clock.phase === 'loadUnload') {
|
||||||
const work = pickFirst(
|
const work =
|
||||||
options,
|
pickFirst(options, 'porter.board', 'porter.detrain', 'laborer.advanceLoad') ??
|
||||||
'porter.board',
|
// Only START a load that can FINISH. A load leaves MEN|AT|WORK onto a spotted empty car of
|
||||||
'porter.detrain',
|
// its own type (§9.3), so starting one with no car spotted parks it on WORK — which locks
|
||||||
'laborer.advanceLoad',
|
// the industry track, which blocks the very car that would clear it. A self-inflicted
|
||||||
'laborer.startLoad',
|
// deadlock, and the loop that was eating the game: 415 loads started, 413 unjams, and 13
|
||||||
'laborer.beginUnload',
|
// 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]!;
|
return work ?? options.find((i) => i.type === 'loadUnload.end') ?? options[0]!;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,6 +137,12 @@ 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
|
// 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.
|
// 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')!;
|
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
|
// A Mainline modifier is worth the option too: Realignment permanently converts a 30 card into a
|
||||||
@@ -148,6 +159,12 @@ function chooseLocalOption(s: GameState, player: PlayerIndex, options: Intent[])
|
|||||||
return can('switch')!;
|
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
|
// 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.
|
// is already spotted. Stocking without one just fills the box.
|
||||||
if (can('freightAgent') && canStockProductively(s, player)) return can('freightAgent')!;
|
if (can('freightAgent') && canStockProductively(s, player)) return can('freightAgent')!;
|
||||||
@@ -262,6 +279,11 @@ function bestTrackLay(s: GameState, player: PlayerIndex, options: Intent[]): Int
|
|||||||
return best;
|
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 {
|
function isMainlineKey(s: GameState, cardId: string, key: string): boolean {
|
||||||
const k = s.cards.get(cardId)?.kind;
|
const k = s.cards.get(cardId)?.kind;
|
||||||
return k?.kind === 'mainlineModifier' && k.key === key;
|
return k?.kind === 'mainlineModifier' && k.key === key;
|
||||||
@@ -295,6 +317,52 @@ function worthFlagging(s: GameState, options: Intent[]): Intent | null {
|
|||||||
return null;
|
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. */
|
/** Once an option is chosen, work it to a sensible conclusion. */
|
||||||
function followThrough(s: GameState, player: PlayerIndex, options: Intent[]): Intent {
|
function followThrough(s: GameState, player: PlayerIndex, options: Intent[]): Intent {
|
||||||
switch (s.turn.option) {
|
switch (s.turn.option) {
|
||||||
@@ -314,7 +382,18 @@ function followThrough(s: GameState, player: PlayerIndex, options: Intent[]): In
|
|||||||
const any = options.find((i) => i.type === 'draw.fromDepartment');
|
const any = options.find((i) => i.type === 'draw.fromDepartment');
|
||||||
if (any) return any;
|
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(
|
const train = options.find(
|
||||||
(i) => i.type === 'card.play' && i.placement === undefined && isTrainCard(s, i.cardId),
|
(i) => i.type === 'card.play' && i.placement === undefined && isTrainCard(s, i.cardId),
|
||||||
);
|
);
|
||||||
@@ -333,6 +412,16 @@ function followThrough(s: GameState, player: PlayerIndex, options: Intent[]): In
|
|||||||
// Enhancements next: Small Yard makes switching solvable, Interlocking stops the Office
|
// 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
|
// overflowing into a collision, and the dispatch devices win meets. All are worth more than
|
||||||
// another piece of plain track.
|
// 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(
|
const enh = options.find(
|
||||||
(i) => i.type === 'card.play' && i.placement !== undefined && isEnhancement(s, i.cardId),
|
(i) => i.type === 'card.play' && i.placement !== undefined && isEnhancement(s, i.cardId),
|
||||||
);
|
);
|
||||||
@@ -360,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
|
// 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
|
// 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.
|
// 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)) {
|
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');
|
const stop = options.find((i) => i.type === 'switch.end');
|
||||||
if (stop) return stop;
|
if (stop) return stop;
|
||||||
}
|
}
|
||||||
@@ -397,45 +504,38 @@ function followThrough(s: GameState, player: PlayerIndex, options: Intent[]): In
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Flying Switch — roll a cut straight into a neighbouring industry without the engine
|
|
||||||
// entering. It beats a normal spot whenever the industry actually wants the back car, since
|
|
||||||
// it saves the moves of running in and backing out again.
|
|
||||||
const flying = options.find(
|
|
||||||
(i) =>
|
|
||||||
i.type === 'maneuver.flyingSwitch' &&
|
|
||||||
i.count === 1 &&
|
|
||||||
facilityWantsAt(s, player, i.to, tray?.consist[tray.consist.length - 1]),
|
|
||||||
);
|
|
||||||
if (flying) return flying;
|
|
||||||
|
|
||||||
if (endCar && here) {
|
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)) {
|
if (hereFacility && facilityWants(hereFacility, endCar)) {
|
||||||
const drop = options.find((i) => i.type === 'switch.dropCars' && i.count === 1);
|
const drop = options.find((i) => i.type === 'switch.dropCars' && i.count === 1);
|
||||||
if (drop) return drop;
|
if (drop) return drop;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Otherwise head for a facility that does want this particular car.
|
// SET OUT — and do it BEFORE travelling.
|
||||||
const wanted = facilitiesWanting(s, player, endCar);
|
|
||||||
const toward = options.find(
|
|
||||||
(i) =>
|
|
||||||
i.type === 'switch.move' &&
|
|
||||||
wanted.some((t) => t.row === i.to.row && t.col === i.to.col),
|
|
||||||
);
|
|
||||||
if (toward) return toward;
|
|
||||||
|
|
||||||
// SET OUT. A crew at MAX_CONSIST cannot couple anything — reachableDestinations drops any
|
|
||||||
// route whose pickups would overflow the tray — so a full crew can never collect the loaded
|
|
||||||
// car it came for. Measured: trays sat at 4 cars for 3,091 of 4,085 in-district observations
|
|
||||||
// and coupled 1 car across 40 games, which is why freight revenue was 0.3 a game.
|
|
||||||
//
|
//
|
||||||
// Real crews set out cars they are not using. Prefer a plain spur off the Running Track:
|
// Two cases. A crew at MAX_CONSIST cannot couple anything, because reachableDestinations
|
||||||
// dropping onto an industry track would silt it with the wrong commodity (the bug the
|
// drops any route whose pickups would overflow the tray, so it can never collect the loaded
|
||||||
// spotting rule above exists to prevent), and the Running Track needs to stay clear.
|
// car it came for. And a crew whose back car is dead weight cannot deliver the wanted car
|
||||||
if (tray && tray.consist.length >= MAX_CONSIST) {
|
// hiding behind it, because §A.3 only lets the back car come off.
|
||||||
const area = areaOf(s, player);
|
//
|
||||||
const card = area.grid.get(`${here.row},${here.col}`);
|
// Order matters. With travel first, the crew drove to a facility, found its end car
|
||||||
const isSpur = here.row !== area.runningRow && !card?.facility;
|
// 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) {
|
if (isSpur) {
|
||||||
const setOut = options.find((i) => i.type === 'switch.dropCars' && i.count === 1);
|
const setOut = options.find((i) => i.type === 'switch.dropCars' && i.count === 1);
|
||||||
if (setOut) return setOut;
|
if (setOut) return setOut;
|
||||||
@@ -448,10 +548,30 @@ function followThrough(s: GameState, player: PlayerIndex, options: Intent[]): In
|
|||||||
);
|
);
|
||||||
if (toSpur) return toSpur;
|
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' &&
|
||||||
|
wanted.some((t) => t.row === i.to.row && t.col === i.to.col),
|
||||||
|
);
|
||||||
|
if (toward) return toward;
|
||||||
}
|
}
|
||||||
|
|
||||||
const move = options.find((i) => i.type === 'switch.move');
|
// Never take an arbitrary Move. Picking "the first legal move" is what produced the original
|
||||||
if (move) return move;
|
// 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]!;
|
return options.find((i) => i.type === 'switch.end') ?? options[0]!;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -695,11 +815,23 @@ export type PlayOutcome = {
|
|||||||
* Drives a game to completion with the given policy. This is the whole reason the phase driver
|
* 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.
|
* 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(
|
export function playGame(
|
||||||
s: GameState,
|
s: GameState,
|
||||||
policy: BotPolicy,
|
policy: BotPolicy,
|
||||||
pumpFn: (s: GameState) => GameEvent[],
|
pumpFn: (s: GameState) => GameEvent[],
|
||||||
maxTurns = 50_000,
|
maxTurns = 50_000,
|
||||||
|
observe?: PlayObserver,
|
||||||
): PlayOutcome {
|
): PlayOutcome {
|
||||||
const events: GameEvent[] = [];
|
const events: GameEvent[] = [];
|
||||||
const intents: Intent['type'][] = [];
|
const intents: Intent['type'][] = [];
|
||||||
@@ -711,6 +843,7 @@ export function playGame(
|
|||||||
const tally = (batch: GameEvent[]): void => {
|
const tally = (batch: GameEvent[]): void => {
|
||||||
events.push(...batch);
|
events.push(...batch);
|
||||||
for (const e of batch) {
|
for (const e of batch) {
|
||||||
|
observe?.(e, s);
|
||||||
if (e.type === 'trainScheduled') trainsScheduled++;
|
if (e.type === 'trainScheduled') trainsScheduled++;
|
||||||
else if (e.type === 'cardPlayed') cardsPlayed++;
|
else if (e.type === 'cardPlayed') cardsPlayed++;
|
||||||
else if (
|
else if (
|
||||||
|
|||||||
+28
-4
@@ -294,7 +294,13 @@ describe('collisions are automatic (Gap 2)', () => {
|
|||||||
movesUsed: 0,
|
movesUsed: 0,
|
||||||
});
|
});
|
||||||
const ml = s.division.nodes[1];
|
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.clock.phase = 'mainline';
|
||||||
s.movedThisPhase = new Set();
|
s.movedThisPhase = new Set();
|
||||||
@@ -311,7 +317,13 @@ describe('collisions are automatic (Gap 2)', () => {
|
|||||||
const classBefore = s.yards.classificationYard.length;
|
const classBefore = s.yards.classificationYard.length;
|
||||||
|
|
||||||
const mlx = s.division.nodes[1];
|
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', {
|
s.trays.set('x', {
|
||||||
id: 'x',
|
id: 'x',
|
||||||
trainNumber: 8,
|
trainNumber: 8,
|
||||||
@@ -354,7 +366,13 @@ describe('the Superintendent clearance interrupt (§8.1)', () => {
|
|||||||
movesUsed: 0,
|
movesUsed: 0,
|
||||||
});
|
});
|
||||||
const ml = s.division.nodes[1];
|
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.
|
// A second train at the Western Division Point wanting to follow it.
|
||||||
s.trays.set('behind', {
|
s.trays.set('behind', {
|
||||||
@@ -391,7 +409,13 @@ describe('the Superintendent clearance interrupt (§8.1)', () => {
|
|||||||
movesUsed: 0,
|
movesUsed: 0,
|
||||||
});
|
});
|
||||||
const ml = s.division.nodes[1];
|
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', {
|
s.trays.set('waiting', {
|
||||||
id: 'waiting',
|
id: 'waiting',
|
||||||
|
|||||||
@@ -189,7 +189,13 @@ describe('Interlocking and Yard Office relieve the Office', () => {
|
|||||||
consist, direction: 'east', position: { at: 'mainline', index: 1 }, movesUsed: 0,
|
consist, direction: 'east', position: { at: 'mainline', index: 1 }, movesUsed: 0,
|
||||||
});
|
});
|
||||||
const ml = s.division.nodes[1];
|
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.clock.phase = 'mainline';
|
||||||
s.movedThisPhase = new Set();
|
s.movedThisPhase = new Set();
|
||||||
return id;
|
return id;
|
||||||
|
|||||||
+52
-15
@@ -71,31 +71,47 @@ describe('grade modifiers change crossing time', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('Brakeman speeds the descent but not the climb', () => {
|
it('Brakeman speeds the descent but not the climb', () => {
|
||||||
// ASSUMPTION (§10 Q11): grades climb eastward, so westbound is downhill.
|
// Q11 — the card prints "(Up)" and "Player sets orientation", so the last argument is which
|
||||||
assert.equal(crossingStages('heavyGrade', 'fast', false, ['brakeman'], 'west'), 1);
|
// way is UPHILL. With up = east, a westbound train is descending.
|
||||||
assert.equal(crossingStages('heavyGrade', 'fast', false, ['brakeman'], 'east'), 2);
|
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', () => {
|
it('Helpers speed the climb but not the descent', () => {
|
||||||
assert.equal(crossingStages('heavyGrade', 'fast', false, ['helpers'], 'east'), 1);
|
assert.equal(crossingStages('heavyGrade', 'fast', false, ['helpers'], 'east', 'east'), 1);
|
||||||
assert.equal(crossingStages('heavyGrade', 'fast', false, ['helpers'], 'west'), 2);
|
assert.equal(crossingStages('heavyGrade', 'fast', false, ['helpers'], 'west', 'east'), 2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Airbrakes stack with Brakeman on a slow train', () => {
|
it('Airbrakes stack with Brakeman on a slow train', () => {
|
||||||
// A slow train pays 3 on a grade; Brakeman and Airbrakes take one Stage each.
|
// A slow train pays 3 on a grade; Brakeman and Airbrakes take one Stage each.
|
||||||
assert.equal(crossingStages('heavyGrade', 'slow', false, [], 'west'), 3);
|
assert.equal(crossingStages('heavyGrade', 'slow', false, [], 'west', 'east'), 3);
|
||||||
assert.equal(crossingStages('heavyGrade', 'slow', false, ['brakeman'], 'west'), 2);
|
assert.equal(crossingStages('heavyGrade', 'slow', false, ['brakeman'], 'west', 'east'), 2);
|
||||||
assert.equal(crossingStages('heavyGrade', 'slow', false, ['brakeman', 'airbrakes'], 'west'), 1);
|
assert.equal(
|
||||||
|
crossingStages('heavyGrade', 'slow', false, ['brakeman', 'airbrakes'], 'west', 'east'),
|
||||||
|
1,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('never lets a train cross in no time', () => {
|
it('never lets a train cross in no time', () => {
|
||||||
assert.equal(crossingStages('heavyGrade', 'fast', false, ['brakeman', 'airbrakes'], 'west'), 1);
|
assert.equal(
|
||||||
|
crossingStages('heavyGrade', 'fast', false, ['brakeman', 'airbrakes'], 'west', 'east'),
|
||||||
|
1,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('leaves non-grade cards alone', () => {
|
it('leaves non-grade cards alone', () => {
|
||||||
// Brakeman on Plains would be an illegal placement anyway; the maths must not move regardless.
|
// Brakeman on Plains would be an illegal placement anyway; the maths must not move regardless.
|
||||||
assert.equal(crossingStages('plains', 'fast', false, ['brakeman'], 'west'), 1);
|
assert.equal(crossingStages('plains', 'fast', false, ['brakeman'], 'west', 'east'), 1);
|
||||||
assert.equal(crossingStages('curves', 'fast', false, ['helpers'], 'east'), 2);
|
assert.equal(crossingStages('curves', 'fast', false, ['helpers'], 'east', 'east'), 2);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -265,8 +281,10 @@ describe('Flying Switch rolls a cut into an industry', () => {
|
|||||||
/** A crew on a straight with a facility next door, mid-switch. */
|
/** A crew on a straight with a facility next door, mid-switch. */
|
||||||
function setup(s: GameState) {
|
function setup(s: GameState) {
|
||||||
const area = areaOf(s, 0);
|
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 = {
|
const spur: TrackCard = {
|
||||||
geometry: { kind: 'track', geometry: 'straight' },
|
geometry: { kind: 'track', geometry: 'straight', axis: 'ns' },
|
||||||
baseOperationalRail: true,
|
baseOperationalRail: true,
|
||||||
standing: [],
|
standing: [],
|
||||||
facility: null,
|
facility: null,
|
||||||
@@ -275,7 +293,7 @@ describe('Flying Switch rolls a cut into an industry', () => {
|
|||||||
};
|
};
|
||||||
area.grid.set(coordKey(at(-1, 0)), spur);
|
area.grid.set(coordKey(at(-1, 0)), spur);
|
||||||
const industry: TrackCard = {
|
const industry: TrackCard = {
|
||||||
geometry: { kind: 'track', geometry: 'straight' },
|
geometry: { kind: 'track', geometry: 'straight', axis: 'ns' },
|
||||||
baseOperationalRail: true,
|
baseOperationalRail: true,
|
||||||
standing: [],
|
standing: [],
|
||||||
facility: {
|
facility: {
|
||||||
@@ -296,7 +314,11 @@ describe('Flying Switch rolls a cut into an industry', () => {
|
|||||||
s.trays.set('crew', {
|
s.trays.set('crew', {
|
||||||
id: 'crew', trainNumber: 1, trainIsExtra: false, engineFront: true,
|
id: 'crew', trainNumber: 1, trainIsExtra: false, engineFront: true,
|
||||||
consist: [{ type: 'hopper', loaded: false }, { type: 'boxcar', loaded: false }],
|
consist: [{ type: 'hopper', loaded: false }, { type: 'boxcar', loaded: false }],
|
||||||
direction: 'east', position: { at: 'grid', owner: 0, coord: at(-1, 0) }, movesUsed: 0,
|
// 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.phase = 'localOps';
|
||||||
s.clock.currentActor = 0;
|
s.clock.currentActor = 0;
|
||||||
@@ -340,13 +362,28 @@ describe('Flying Switch rolls a cut into an industry', () => {
|
|||||||
const s = game();
|
const s = game();
|
||||||
setup(s);
|
setup(s);
|
||||||
const cardId = hand(s, 'maneuver', 'flyingSwitch');
|
const cardId = hand(s, 'maneuver', 'flyingSwitch');
|
||||||
// (0,0) is the Office, adjacent to the crew but not a freight industry.
|
// (0,0) is the Office — track-connected to the crew, but not a freight industry.
|
||||||
assert.equal(
|
assert.equal(
|
||||||
check(s, 0, { type: 'maneuver.flyingSwitch', cardId, trayId: 'crew', count: 1, to: at(0, 0) }),
|
check(s, 0, { type: 'maneuver.flyingSwitch', cardId, trayId: 'crew', count: 1, to: at(0, 0) }),
|
||||||
'CANNOT_DROP_HERE',
|
'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', () => {
|
it('cannot cut more cars than it is hauling', () => {
|
||||||
const s = game();
|
const s = game();
|
||||||
setup(s);
|
setup(s);
|
||||||
|
|||||||
+25
-11
@@ -49,17 +49,19 @@ const newSolitaireGame = (seed = 1234) =>
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
describe('card catalogue (component 1)', () => {
|
describe('card catalogue (component 1)', () => {
|
||||||
it('composes the 133-card deck from the design', () => {
|
it('composes the 140-card deck from the design', () => {
|
||||||
// Transcribed from docs/Deck cards2.xlsx, whose own total is 115 — plus the 18 extra industry
|
// Transcribed from docs/Deck cards2.xlsx, whose own total is 115 — plus 18 extra industry cards
|
||||||
// cards added for Gap 12, taking industries from 9 to 27 (see INDUSTRY_PROFILES).
|
// (Gap 12, industries 9 → 27) and 7 extra office cards (Q12, offices 7 → 14). Both are
|
||||||
assert.equal(DECK_SIZE, 133);
|
// deliberate departures from the sheet and both are flagged provisional in content.ts.
|
||||||
|
assert.equal(DECK_SIZE, 140);
|
||||||
assert.equal(buildDeck().length, DECK_SIZE);
|
assert.equal(buildDeck().length, DECK_SIZE);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('matches the design deck composition exactly', () => {
|
it('matches the design deck composition exactly', () => {
|
||||||
const byCategory = Object.fromEntries(deckComposition().map((c) => [c.category, c.count]));
|
const byCategory = Object.fromEntries(deckComposition().map((c) => [c.category, c.count]));
|
||||||
assert.deepEqual(byCategory, {
|
assert.deepEqual(byCategory, {
|
||||||
office: 7,
|
// 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.
|
// 27, not the sheet's 9 — Gap 12 industry density; see INDUSTRY_PROFILES.
|
||||||
industry: 27,
|
industry: 27,
|
||||||
modifier: 23,
|
modifier: 23,
|
||||||
@@ -74,13 +76,13 @@ describe('card catalogue (component 1)', () => {
|
|||||||
|
|
||||||
it('removes opponent-directed cards from a solitaire deck', () => {
|
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
|
// Q6 — Space-use and Action cards can only be played AT another player, so in a one-player
|
||||||
// game they would be 22 of 133 draws (17%) that do nothing.
|
// game they would be 22 of 140 draws (16%) that do nothing.
|
||||||
assert.equal(SOLITAIRE_DECK_SIZE, 111);
|
assert.equal(SOLITAIRE_DECK_SIZE, 118);
|
||||||
const solo = buildDeck('solitaire');
|
const solo = buildDeck('solitaire');
|
||||||
assert.equal(solo.length, 111);
|
assert.equal(solo.length, 118);
|
||||||
assert.ok(!solo.some((c) => c.kind.kind === 'spaceUse' || c.kind.kind === 'action'));
|
assert.ok(!solo.some((c) => c.kind.kind === 'spaceUse' || c.kind.kind === 'action'));
|
||||||
// A competitive deck keeps them.
|
// A competitive deck keeps them.
|
||||||
assert.equal(buildDeck('competitive').length, 133);
|
assert.equal(buildDeck('competitive').length, 140);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps track OUT of the deck, as a per-player supply', () => {
|
it('keeps track OUT of the deck, as a per-player supply', () => {
|
||||||
@@ -181,8 +183,20 @@ describe('card catalogue (component 1)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('forms an office pyramid so the strict upgrade sequence cannot stall', () => {
|
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);
|
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', () => {
|
it('walks the upgrade sequence without skipping', () => {
|
||||||
@@ -344,7 +358,7 @@ describe('game setup (component 2)', () => {
|
|||||||
...g.decks.salvageYard,
|
...g.decks.salvageYard,
|
||||||
...[...g.decks.hands.values()].flat(),
|
...[...g.decks.hands.values()].flat(),
|
||||||
];
|
];
|
||||||
// A solitaire deck omits the 22 opponent-directed cards (Q6), so it holds 111, not 133.
|
// 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(all.length, SOLITAIRE_DECK_SIZE, 'cards lost or duplicated');
|
||||||
assert.equal(new Set(all).size, SOLITAIRE_DECK_SIZE, 'duplicate card ids');
|
assert.equal(new Set(all).size, SOLITAIRE_DECK_SIZE, 'duplicate card ids');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -287,3 +287,34 @@ describe('switching accomplishes something (regression)', () => {
|
|||||||
assert.ok(moves < 60, `${moves.toFixed(0)} Moves per game suggests aimless shuttling`);
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user