Implement Enhancements, Mainline modifiers and Maneuvers; fix unplayable track

This commit is contained in:
Jesse
2026-07-31 11:02:54 -04:00
parent 401b879140
commit b085d5a0bb
20 changed files with 1853 additions and 50 deletions
+86 -3
View File
@@ -50,14 +50,97 @@ 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.
> **Open question (Q11).** The sheet says Brakeman and Airbrakes give "faster passage downhill" and
> Helpers "faster passage uphill", but never says **which way a Heavy Grade climbs**. Modelled as
> climbing **eastward** — eastbound is uphill, westbound downhill. If grades instead carry their own
> direction, this becomes a property of the Mainline node rather than a constant.
**Still not implemented**: the Action (10) and Space-use (12) cards, which are genuinely
multiplayer-only. Playing them is rejected with `NOT_IMPLEMENTED`.
### 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.
> **Open question (Q10).** The design moves track to a per-player supply but never says *when* a
> piece may be laid. It is currently treated as a play available during the "draw a card" option,
> 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
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.
+114 -2
View File
@@ -19,6 +19,7 @@
import {
COLLISION_PENALTY,
MAINLINE_PROFILES,
enhancementRule,
consistSize,
crossingStages,
trainProfile,
@@ -349,11 +350,57 @@ 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.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 +496,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 +552,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 +594,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 +737,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;
+366 -4
View File
@@ -19,15 +19,20 @@ import {
HAND_LIMIT,
LABORER_ACTIONS_PER_LOAD,
MAX_CONSIST,
REALIGNMENTS,
enhancementRule,
industryProfile,
mainlineModifierRule,
mainlineProfile,
modifierProfile,
nextOfficeTier,
officeProfile,
} from './content.ts';
import type { CarType, FreightKind, ModifierKind, TrackGeometry } from './content.ts';
import type { CarType, FreightKind, Hand, MainlineKind, ModifierKind, TrackGeometry } from './content.ts';
import type { GameEvent } from './events.ts';
import type { Intent, RejectionCode } from './intents.ts';
import type {
CardId,
Facility,
GameState,
GridCoord,
@@ -272,6 +277,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 +331,78 @@ 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 rolls into an ADJACENT industry — the engine never enters, which is the whole point
// of the move and why it beats a normal spot.
const adjacent =
Math.abs(i.to.row - here.row) + Math.abs(i.to.col - here.col) === 1;
if (!adjacent) 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 +574,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
@@ -552,6 +667,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 +736,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 +772,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 } },
@@ -787,6 +965,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 +1019,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 +1028,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 +1130,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 +1278,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 +1363,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.
+128 -17
View File
@@ -145,13 +145,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 +391,8 @@ export function crossingStages(
kind: MainlineKind,
trainSpeed: TrainSpeed,
carriesPassengers: boolean,
modifiers: readonly string[] = [],
direction: Direction = 'east',
): number {
const profile = MAINLINE_PROFILES.find((m) => m.kind === kind);
if (!profile) throw new Error(`unknown mainline card: ${kind}`);
@@ -394,14 +406,66 @@ 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));
}
/**
* ASSUMPTION, flagged (§10 Q11): the recovered sheet says Brakeman and Airbrakes give "faster
* passage downhill" and Helpers "faster passage uphill", but never says which way a Heavy Grade
* climbs. Modelled as **climbing eastward** — an eastbound train is going uphill, a westbound one
* 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.
* Airbrakes only counts when Brakeman is already there, which the placement rule enforces.
*/
function gradeReduction(
profile: MainlineProfile,
modifiers: readonly string[],
direction: Direction,
): number {
if (profile.speed.kind !== 'grade') return 0;
const downhill = direction === 'west';
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 +495,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 +587,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 +648,7 @@ export function lengthProfile(length: GameLength): LengthProfile {
}
// ---------------------------------------------------------------------------
// Deck composition — 115 cards
// Deck composition — 133 cards
// ---------------------------------------------------------------------------
/**
@@ -565,9 +676,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); // 133
/** The solitaire deck drops the 22 opponent-directed cards, leaving 93. */
/** The solitaire deck drops the 22 opponent-directed cards, leaving 111. */
export const SOLITAIRE_DECK_SIZE = deckComposition()
.filter((c) => !isOpponentOnly(c.category))
.reduce((n, c) => n + c.count, 0);
+8
View File
@@ -24,9 +24,14 @@ export type GameEvent =
| { type: 'trayMoved'; trayId: TrayId; from: GridCoord; to: GridCoord; movesRemaining: number }
| { 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
+34 -1
View File
@@ -4,7 +4,7 @@
* An intent is a PROPOSAL. It may be rejected. Contrast with an event (events.ts), which is a fact.
*/
import type { CarType, Direction, OfficeTier } from './content.ts';
import type { CarType, Direction, Hand, OfficeTier, TrackGeometry } from './content.ts';
import type { CardId, GridCoord, PlayerIndex, TrayId } from './state.ts';
// ---------------------------------------------------------------------------
@@ -18,6 +18,12 @@ export type Intent =
// -- switch (§6.1, Appendix A)
| { type: 'switch.move'; trayId: TrayId; to: GridCoord; reverse: boolean }
| { type: 'switch.dropCars'; trayId: TrayId; count: number }
/**
* Small Yard enhancement — "a train that spends one move in the yard may sort itself in any
* order, including cars in front of the engine". This is the designed answer to §A.3's
* come-off-in-seated-order constraint, which is what makes facing-point work possible.
*/
| { type: 'switch.sortConsist'; trayId: TrayId; order: number[] }
| { type: 'switch.end' }
// -- draw (§6.2)
| { type: 'draw.fromHomeOffice' }
@@ -25,6 +31,14 @@ 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).
*
* ASSUMPTION, flagged: the design moves track out of the deck into a per-player supply but does
* not say when you may lay it. Treated as a play available during the "draw a card" option,
* 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: 'draw.end' }
// -- freight agent (§6.3)
| { type: 'freightAgent.stockOutbound'; at: GridCoord; carType: CarType }
@@ -37,6 +51,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 +112,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 };
+68 -2
View File
@@ -12,7 +12,7 @@
* If you find yourself writing a rule here, it belongs in apply.ts.
*/
import type { CarType } from './content.ts';
import type { CarType, Hand, TrackGeometry } from './content.ts';
import { check, areaOf, destinationsFor } from './apply.ts';
import type { Intent } from './intents.ts';
import type { GameState, GridCoord, PlayerIndex } from './state.ts';
@@ -56,6 +56,14 @@ function candidates(s: GameState, player: PlayerIndex): Intent[] {
break;
}
// Red Flags — "any time", so they are candidates in every phase, on any train standing out on
// the Mainline (the player's own or another's: protecting a train is not an attack).
for (const cardId of s.decks.hands.get(player) ?? []) {
const k = s.cards.get(cardId)?.kind;
if (k?.kind !== 'maneuver' || k.key !== 'redFlags') continue;
for (const [trayId] of s.trays) out.push({ type: 'maneuver.redFlags', cardId, trayId });
}
out.push({ type: 'redFlag.play' });
return out;
}
@@ -82,6 +90,38 @@ 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 { row, col } = tray.position.coord;
const around: GridCoord[] = [
{ row: row + 1, col },
{ row: row - 1, col },
{ row, col: col + 1 },
{ 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 });
}
}
}
}
out.push({ type: 'switch.end' });
@@ -90,17 +130,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)
+16 -1
View File
@@ -17,6 +17,7 @@ import {
MODIFIER_PROFILES,
OFFICE_PROFILES,
MAINLINE_PROFILES,
TRACK_SUPPLY,
ROLLING_STOCK_SUPPLY,
STAGES_PER_DAY,
TIMETABLED_TRAINS,
@@ -122,6 +123,7 @@ function buildOfficeArea(owner: PlayerIndex): OfficeArea {
standing: [],
facility: buildPassengerFacility('whistlePost'),
modifiers: [],
enhancements: [],
};
const limitsCard = (): TrackCard => ({
@@ -130,6 +132,7 @@ function buildOfficeArea(owner: PlayerIndex): OfficeArea {
standing: [],
facility: null,
modifiers: [],
enhancements: [],
});
const grid = new Map<string, TrackCard>();
@@ -137,7 +140,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])),
};
}
/**
+33 -2
View File
@@ -73,7 +73,9 @@ export type CardGeometry =
| { kind: 'limits' }
| { kind: 'facility'; facility: FreightKind; axis?: TrackAxis }
/** Not track — a Modifier sits beside a Facility and raises its capacity (§9). */
| { kind: 'modifier'; modifier: ModifierKind };
| { kind: 'modifier'; modifier: ModifierKind }
/** Not track — a Space-use card played at a district to consume a cell (Q6). */
| { kind: 'spaceUse'; key: string };
export type TrackCard = {
geometry: CardGeometry;
@@ -86,6 +88,8 @@ export type TrackCard = {
standing: RollingStock[];
facility: Facility | null;
modifiers: ModifierKind[];
/** Enhancement cards laid on this card (§7 of implications.md). */
enhancements: string[];
};
export type OfficeArea = {
@@ -99,6 +103,18 @@ export type OfficeArea = {
limitsEast: GridCoord;
/** Trays holding at the Office. Length must never exceed the tier's A/D track count. */
adOccupancy: TrayId[];
/**
* Trains held at the Limits by an Interlocking rather than admitted to the Office. They are
* inside the player's Limits but not occupying an A/D track.
*/
heldAtLimits: TrayId[];
/** Dispatch bonuses spent this Day, by enhancement key — each is once a Day. */
dispatchUsedToday: string[];
/**
* The player's personal track supply (26 pieces), keyed `geometry:hand`. Track is NOT in the
* Home Office deck — it is laid from here.
*/
trackSupply: Map<string, number>;
};
// ---------------------------------------------------------------------------
@@ -198,7 +214,16 @@ 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[];
/** 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 +353,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 +368,7 @@ export function freshTurn(moves: number): TurnState {
option: null,
movesRemaining: moves,
drawnThisTurn: false,
laidThisTurn: false,
freightAgentUsed: false,
done: false,
};
+2 -1
View File
@@ -79,8 +79,9 @@ type PortPair = readonly [Port, Port];
*/
function connectionsFor(card: TrackCard): readonly PortPair[] {
switch (card.geometry.kind) {
// A Modifier is not track: nothing connects to it and no train may enter (§9).
// Neither a Modifier nor a Space-use card is track: nothing connects, no train may enter.
case 'modifier':
case 'spaceUse':
return [];
case 'limits':
return [['e', 'w']];
+181
View File
@@ -63,6 +63,11 @@ 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
@@ -129,6 +134,12 @@ function chooseLocalOption(s: GameState, player: PlayerIndex, options: Intent[])
// whose value compounds. Playing one needs the draw option.
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
@@ -163,6 +174,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 +233,68 @@ 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 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;
}
/** Once an option is chosen, work it to a sensible conclusion. */
function followThrough(s: GameState, player: PlayerIndex, options: Intent[]): Intent {
switch (s.turn.option) {
@@ -238,6 +320,32 @@ function followThrough(s: GameState, player: PlayerIndex, options: Intent[]): In
);
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.
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;
@@ -272,6 +380,34 @@ 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;
}
}
}
// 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) {
const hereFacility = areaOf(s, player).grid.get(`${here.row},${here.col}`)?.facility ?? null;
if (hereFacility && facilityWants(hereFacility, endCar)) {
@@ -287,6 +423,31 @@ function followThrough(s: GameState, player: PlayerIndex, options: Intent[]): In
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:
// dropping onto an industry track would silt it with the wrong commodity (the bug the
// spotting rule above exists to prevent), and the Running Track needs to stay clear.
if (tray && tray.consist.length >= MAX_CONSIST) {
const area = areaOf(s, player);
const card = area.grid.get(`${here.row},${here.col}`);
const isSpur = here.row !== area.runningRow && !card?.facility;
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;
}
}
const move = options.find((i) => i.type === 'switch.move');
@@ -410,6 +571,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) {
+46
View File
@@ -124,6 +124,12 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
where: e.at,
text: `Coupled ${e.stock.length} car(s) at ${at(e.at)}: ${carsLabel(e.stock)}`,
};
case 'consistSorted':
return {
tone: 'good',
where: e.at,
text: `SMALL YARD — consist re-ordered from [${carsLabel(e.before)}] to [${carsLabel(e.after)}], so the right car is now on the end and can be spotted`,
};
case 'carsDropped':
return {
tone: 'plain',
@@ -148,6 +154,30 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
? `Played ${card(e.cardId)} onto ${at(e.placement)}`
: `Played ${card(e.cardId)}`,
};
case 'mainlineModified':
return {
tone: 'plain',
text: e.became
? `Realignment: Mainline card ${e.node} converted to ${e.became}`
: `Played ${e.key} on Mainline card ${e.node}`,
};
case 'redFlagsSet':
return {
tone: 'good',
text: `Red Flags set out to protect train ${e.trayId} on Mainline card ${e.node} — an approaching train must stop`,
};
case 'flyingSwitch':
return {
tone: 'good',
where: e.to,
text: `Flying Switch — ${e.stock.length} car(s) cut loose and rolled into the industry at ${at(e.to)} without the engine entering`,
};
case 'trackLaid':
return {
tone: 'plain',
where: e.at,
text: `Laid ${e.hand === 'none' ? '' : e.hand + '-hand '}${e.geometry} track at ${at(e.at)}${e.remaining} left in the supply`,
};
case 'officeUpgraded':
return { tone: 'good', text: `OFFICE UPGRADED — ${e.from}${e.to}` };
case 'cardDiscarded':
@@ -169,6 +199,12 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
tone: 'good',
text: `Extra X${e.trainNumber} played — it is NOT scheduled; it runs once as soon as a Crew Tray frees up, then its card is gone`,
};
case 'enhancementPlaced':
return {
tone: 'good',
where: e.at,
text: `ENHANCEMENT built: ${e.key.replace(/([A-Z])/g, ' $1')} at ${at(e.at)}`,
};
case 'secondSectionOrdered':
return {
tone: 'bad',
@@ -194,6 +230,11 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
tone: 'plain',
text: `Train ${e.trainNumber} ARRIVED at the ${e.office} carrying ${carsLabel(e.consist)} — it will highball again next Mainline Phase, so any work must happen now`,
};
case 'trainDiverted':
return {
tone: 'good',
text: `Train ${e.trainNumber} DIVERTED to ${e.to}${e.reason}`,
};
case 'trainCompleted':
return {
tone: 'quiet',
@@ -238,6 +279,11 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
return { tone: 'plain', text: `Added a ${carLabel(e.stock)} to the train being made up` };
case 'carPassed':
return { tone: 'quiet', text: 'Passed — no suitable car in the Division Yard' };
case 'dispatchBonusUsed':
return {
tone: 'good',
text: `${e.key.toUpperCase()} used (+${e.bonus}) — Train ${e.trainNumber} wins the meet against Train ${e.againstTrain}, which now counts as number ${e.againstTrain + e.bonus}. Once a Day only.`,
};
case 'clearanceRequested':
return {
tone: 'bad',
+1
View File
@@ -154,6 +154,7 @@ function snapshot(
else if (g.kind === 'limits') label = 'Limits';
else if (g.kind === 'facility') label = FACILITY_NAMES[g.facility] ?? g.facility;
else if (g.kind === 'modifier') label = MODIFIER_NAMES[g.modifier] ?? g.modifier;
else if (g.kind === 'spaceUse') label = prettyKey(g.key);
else label = g.geometry === 'sharpCurved' ? 'sharp curve' : g.geometry;
const fv = facilityView(card as never, officeProfile(area.tier).name);
+3
View File
@@ -220,6 +220,9 @@ const EXPECTED_EVENTS = [
'carPlacedOnTrain',
'trayMoved',
'carsCoupled',
'mainlineModified',
'redFlagsSet',
'flyingSwitch',
'carsDropped',
'cardDrawn',
'cardPlayed',
+3 -3
View File
@@ -9,7 +9,7 @@ import assert from 'node:assert/strict';
import { advance, pump } from '../src/engine/advance.ts';
import { applyIntent } from '../src/engine/apply.ts';
import { STAGES_PER_DAY, lengthProfile } from '../src/engine/content.ts';
import { STAGES_PER_DAY, lengthProfile, TOTAL_ROLLING_STOCK } from '../src/engine/content.ts';
import { legalActions } from '../src/engine/legal.ts';
import { createGame } from '../src/engine/setup.ts';
import type { GameConfig, GameState } from '../src/engine/state.ts';
@@ -523,7 +523,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 +539,6 @@ describe('MILESTONE: a full solitaire game runs headless', () => {
}
}
}
assert.equal(count, 62, 'rolling stock leaked or was duplicated');
assert.equal(count, TOTAL_ROLLING_STOCK, 'rolling stock leaked or was duplicated');
});
});
+4
View File
@@ -57,6 +57,7 @@ const straight = (standing: TrackCard['standing'] = []): TrackCard => ({
standing,
facility: null,
modifiers: [],
enhancements: [],
});
// ---------------------------------------------------------------------------
@@ -325,6 +326,7 @@ describe('Freight Agent operations (§6.3)', () => {
usedThisStage: { laborers: 0, porters: 0 },
},
modifiers: [],
enhancements: [],
});
return coord;
}
@@ -421,6 +423,7 @@ describe('Load/Unload: the four-action freight pipeline (§9.3)', () => {
usedThisStage: { laborers: 0, porters: 0 },
},
modifiers: [],
enhancements: [],
});
s.clock.phase = 'loadUnload';
return coord;
@@ -561,6 +564,7 @@ describe('legalActions shares its rules with apply (component 6)', () => {
usedThisStage: { laborers: 0, porters: 0 },
},
modifiers: [],
enhancements: [],
});
assert.deepEqual(chooseOptions(s), ['draw', 'freightAgent', 'switch']);
});
+356
View File
@@ -0,0 +1,356 @@
/**
* 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') 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);
});
});
+377
View File
@@ -0,0 +1,377 @@
/**
* 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', () => {
// ASSUMPTION (§10 Q11): grades climb eastward, so westbound is downhill.
assert.equal(crossingStages('heavyGrade', 'fast', false, ['brakeman'], 'west'), 1);
assert.equal(crossingStages('heavyGrade', 'fast', false, ['brakeman'], 'east'), 2);
});
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'], 'west'), 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'), 3);
assert.equal(crossingStages('heavyGrade', 'slow', false, ['brakeman'], 'west'), 2);
assert.equal(crossingStages('heavyGrade', 'slow', false, ['brakeman', 'airbrakes'], 'west'), 1);
});
it('never lets a train cross in no time', () => {
assert.equal(crossingStages('heavyGrade', 'fast', false, ['brakeman', 'airbrakes'], 'west'), 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'), 1);
assert.equal(crossingStages('curves', 'fast', false, ['helpers'], '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);
const spur: TrackCard = {
geometry: { kind: 'track', geometry: 'straight' },
baseOperationalRail: true,
standing: [],
facility: null,
modifiers: [],
enhancements: [],
};
area.grid.set(coordKey(at(-1, 0)), spur);
const industry: TrackCard = {
geometry: { kind: 'track', geometry: 'straight' },
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 }],
direction: 'east', 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, adjacent 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('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);
});
});
+18 -14
View File
@@ -49,9 +49,10 @@ 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 133-card deck from the design', () => {
// Transcribed from docs/Deck cards2.xlsx, whose own total is 115 — plus the 18 extra industry
// cards added for Gap 12, taking industries from 9 to 27 (see INDUSTRY_PROFILES).
assert.equal(DECK_SIZE, 133);
assert.equal(buildDeck().length, DECK_SIZE);
});
@@ -59,7 +60,8 @@ describe('card catalogue (component 1)', () => {
const byCategory = Object.fromEntries(deckComposition().map((c) => [c.category, c.count]));
assert.deepEqual(byCategory, {
office: 7,
industry: 9,
// 27, not the sheet's 9 — Gap 12 industry density; see INDUSTRY_PROFILES.
industry: 27,
modifier: 23,
train: 22,
spaceUse: 12,
@@ -72,13 +74,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 133 draws (17%) that do nothing.
assert.equal(SOLITAIRE_DECK_SIZE, 111);
const solo = buildDeck('solitaire');
assert.equal(solo.length, 93);
assert.equal(solo.length, 111);
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, 133);
});
it('keeps track OUT of the deck, as a per-player supply', () => {
@@ -190,9 +192,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 +344,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 111, not 133.
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 +361,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);
});
+9
View File
@@ -36,6 +36,7 @@ const straight = (standing: RollingStock[] = []): TrackCard => ({
standing,
facility: null,
modifiers: [],
enhancements: [],
});
/** A straight rotated to run north-south. Needed to meet the Office's junction stubs (Gap 11). */
@@ -45,6 +46,7 @@ const nsStraight = (standing: RollingStock[] = []): TrackCard => ({
standing,
facility: null,
modifiers: [],
enhancements: [],
});
const turnout = (o?: TurnoutOrientation): TrackCard => ({
@@ -55,6 +57,7 @@ const turnout = (o?: TurnoutOrientation): TrackCard => ({
standing: [],
facility: null,
modifiers: [],
enhancements: [],
});
const officeCard = (): TrackCard => ({
@@ -63,6 +66,7 @@ const officeCard = (): TrackCard => ({
standing: [],
facility: null,
modifiers: [],
enhancements: [],
});
const lockedFacility = (): TrackCard => ({
@@ -84,6 +88,7 @@ const lockedFacility = (): TrackCard => ({
usedThisStage: { laborers: 0, porters: 0 },
},
modifiers: [],
enhancements: [],
});
const car = (): RollingStock => ({ type: 'boxcar', loaded: false });
@@ -100,6 +105,9 @@ function areaFrom(cards: Record<string, TrackCard>, officeCoord: GridCoord): Off
limitsWest: { row: officeCoord.row, col: officeCoord.col - 2 },
limitsEast: { row: officeCoord.row, col: officeCoord.col + 2 },
adOccupancy: [],
heldAtLimits: [],
dispatchUsedToday: [],
trackSupply: new Map(),
};
}
@@ -159,6 +167,7 @@ describe('ports and geometry', () => {
standing: [],
facility: null,
modifiers: [],
enhancements: [],
};
assert.deepEqual(exitsFrom(r, 's'), ['w']);
});