Implement Enhancements, Mainline modifiers and Maneuvers; fix unplayable track
This commit is contained in:
@@ -50,14 +50,97 @@ All nine answers are implemented, **170 tests passing**:
|
|||||||
| Q8 Crew Tray cap | Already correct. |
|
| 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. |
|
| 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
|
**Enhancements (18) are now implemented** — placement rules, Small Yard consist re-ordering, ABS
|
||||||
(18), Mainline-modifier (7) and Maneuver (7) cards, plus the Action (10) and Space-use (12) cards in
|
Signals, Yard Office diversion, Interlocking, the Telegraph/Telephone/Radio dispatch bonuses and the
|
||||||
multiplayer. All are in the deck; playing them is rejected with `NOT_IMPLEMENTED`.
|
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
|
### Measurement after the answers
|
||||||
|
|
||||||
100 solitaire Standard games: mean revenue **−1.3**, trains scheduled **2.8**, cards played **11.3**.
|
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
|
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
|
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.
|
cards. Gap 12 stays invalidated until the enhancements are in.
|
||||||
|
|||||||
+114
-2
@@ -19,6 +19,7 @@
|
|||||||
import {
|
import {
|
||||||
COLLISION_PENALTY,
|
COLLISION_PENALTY,
|
||||||
MAINLINE_PROFILES,
|
MAINLINE_PROFILES,
|
||||||
|
enhancementRule,
|
||||||
consistSize,
|
consistSize,
|
||||||
crossingStages,
|
crossingStages,
|
||||||
trainProfile,
|
trainProfile,
|
||||||
@@ -349,11 +350,57 @@ function enterMainline(
|
|||||||
): void {
|
): void {
|
||||||
const profile = trainProfile(tray.trainNumber ?? 0, tray.trainIsExtra);
|
const profile = trainProfile(tray.trainNumber ?? 0, tray.trainIsExtra);
|
||||||
const carriesPassengers = tray.consist.some((c) => c.type === 'coach');
|
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 });
|
node.transits.push({ tray: id, stagesRemaining: stages, direction: tray.direction });
|
||||||
tray.position = { at: 'mainline', index };
|
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. */
|
/** Q3 — an expedited train does not spend a Stage standing at the Office. */
|
||||||
function isExpedited(tray: CrewTray): boolean {
|
function isExpedited(tray: CrewTray): boolean {
|
||||||
return trainProfile(tray.trainNumber ?? 0, tray.trainIsExtra)?.rules.expedite === true;
|
return trainProfile(tray.trainNumber ?? 0, tray.trainIsExtra)?.rules.expedite === true;
|
||||||
@@ -449,6 +496,8 @@ function moveTrain(
|
|||||||
const target = index + dir;
|
const target = index + dir;
|
||||||
const dest = s.division.nodes[target];
|
const dest = s.division.nodes[target];
|
||||||
node.transits = node.transits.filter((t) => t.tray !== id);
|
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';
|
if (!dest) return 'held';
|
||||||
|
|
||||||
@@ -503,7 +552,26 @@ function evaluateClearance(
|
|||||||
const otherTray = s.trays.get(other);
|
const otherTray = s.trays.get(other);
|
||||||
if (!otherTray) continue;
|
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).
|
// Same direction — the Superintendent must rule (§8.1, fourth condition).
|
||||||
s.clock.pendingDecision = { train: id, occupiedBy: other };
|
s.clock.pendingDecision = { train: id, occupiedBy: other };
|
||||||
@@ -526,13 +594,55 @@ function arriveAtOffice(
|
|||||||
): MoveOutcome {
|
): MoveOutcome {
|
||||||
const area = areaOf(s, owner);
|
const area = areaOf(s, owner);
|
||||||
const capacity = officeProfile(area.tier).adTracks;
|
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).
|
// Gap 2d — no room at the station is a collision, and it is the local player's fault (§10).
|
||||||
if (area.adOccupancy.length >= capacity) {
|
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');
|
collide(s, owner, [id], events, 'no free A/D track');
|
||||||
return 'moved';
|
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
|
// §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.
|
// is not expecting them (§A.4), so this is a collision too, not a coupling.
|
||||||
const officeCard = area.grid.get(coordKey(area.officeCoord));
|
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) {
|
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.day += 1;
|
||||||
s.clock.stage = 1;
|
s.clock.stage = 1;
|
||||||
s.collisionsToday = 0;
|
s.collisionsToday = 0;
|
||||||
|
|||||||
+366
-4
@@ -19,15 +19,20 @@ import {
|
|||||||
HAND_LIMIT,
|
HAND_LIMIT,
|
||||||
LABORER_ACTIONS_PER_LOAD,
|
LABORER_ACTIONS_PER_LOAD,
|
||||||
MAX_CONSIST,
|
MAX_CONSIST,
|
||||||
|
REALIGNMENTS,
|
||||||
|
enhancementRule,
|
||||||
industryProfile,
|
industryProfile,
|
||||||
|
mainlineModifierRule,
|
||||||
|
mainlineProfile,
|
||||||
modifierProfile,
|
modifierProfile,
|
||||||
nextOfficeTier,
|
nextOfficeTier,
|
||||||
officeProfile,
|
officeProfile,
|
||||||
} from './content.ts';
|
} 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 { GameEvent } from './events.ts';
|
||||||
import type { Intent, RejectionCode } from './intents.ts';
|
import type { Intent, RejectionCode } from './intents.ts';
|
||||||
import type {
|
import type {
|
||||||
|
CardId,
|
||||||
Facility,
|
Facility,
|
||||||
GameState,
|
GameState,
|
||||||
GridCoord,
|
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';
|
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':
|
case 'switch.end':
|
||||||
if (!inPhase(s, 'localOps')) return 'WRONG_PHASE';
|
if (!inPhase(s, 'localOps')) return 'WRONG_PHASE';
|
||||||
return s.turn.option === 'switch' ? null : 'OPTION_NOT_CHOSEN';
|
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;
|
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': {
|
case 'draw.end': {
|
||||||
if (!inPhase(s, 'localOps')) return 'WRONG_PHASE';
|
if (!inPhase(s, 'localOps')) return 'WRONG_PHASE';
|
||||||
if (s.turn.option !== 'draw') return 'OPTION_NOT_CHOSEN';
|
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.
|
// spots) or it does nothing at all, so placing it anywhere else is not a legal play.
|
||||||
return adjacentFacilityCoord(area, placement) ? null : 'NOT_CONNECTED';
|
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':
|
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':
|
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':
|
case 'action':
|
||||||
// Recovered from the design but not yet implemented — see docs/rules/implications.md §7 and
|
// 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
|
// §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 }];
|
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 'switch.end':
|
||||||
case 'draw.end':
|
case 'draw.end':
|
||||||
return [{ type: 'phaseEnded', player, phase: 'localOps' }];
|
return [{ type: 'phaseEnded', player, phase: 'localOps' }];
|
||||||
@@ -607,6 +736,14 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
|
|||||||
to: card.kind.tier,
|
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') {
|
if (card.kind.kind === 'extraTrain') {
|
||||||
// §7 — an Extra runs once, immediately, as soon as a Crew Tray frees up. Playing one used
|
// §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.
|
// 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':
|
case 'card.discard':
|
||||||
return [{ type: 'cardDiscarded', player, cardId: i.cardId, toSlot: i.toSlot }];
|
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':
|
case 'freightAgent.stockOutbound':
|
||||||
return [
|
return [
|
||||||
{ type: 'stockToOutbound', player, at: i.at, stock: { type: i.carType, loaded: true } },
|
{ 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;
|
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': {
|
case 'carsDropped': {
|
||||||
const tray = s.trays.get(e.trayId)!;
|
const tray = s.trays.get(e.trayId)!;
|
||||||
const area = areaOf(s, tray.position.at === 'grid' ? tray.position.owner : 0);
|
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: [],
|
standing: [],
|
||||||
facility: null,
|
facility: null,
|
||||||
modifiers: [],
|
modifiers: [],
|
||||||
|
enhancements: [],
|
||||||
});
|
});
|
||||||
applyModifier(area, e.placement, card.kind.modifier);
|
applyModifier(area, e.placement, card.kind.modifier);
|
||||||
} else if (card.kind.kind !== 'office') {
|
} else if (card.kind.kind !== 'office') {
|
||||||
@@ -841,6 +1028,52 @@ export function reduce(s: GameState, e: GameEvent): void {
|
|||||||
break;
|
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': {
|
case 'officeUpgraded': {
|
||||||
// Gap 8 — a property change, NOT a card swap. Swapping would orphan attached Secondary Track.
|
// Gap 8 — a property change, NOT a card swap. Swapping would orphan attached Secondary Track.
|
||||||
const area = areaOf(s, e.player);
|
const area = areaOf(s, e.player);
|
||||||
@@ -897,6 +1130,19 @@ export function reduce(s: GameState, e: GameEvent): void {
|
|||||||
break;
|
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':
|
case 'extraQueued':
|
||||||
s.pendingExtras.push(e.trainNumber);
|
s.pendingExtras.push(e.trainNumber);
|
||||||
break;
|
break;
|
||||||
@@ -1032,7 +1278,7 @@ function protoCard(
|
|||||||
kind: { kind: string; geometry?: string; facility?: string },
|
kind: { kind: string; geometry?: string; facility?: string },
|
||||||
variant: number | undefined,
|
variant: number | undefined,
|
||||||
): TrackCard | null {
|
): TrackCard | null {
|
||||||
const base = { standing: [], facility: null, modifiers: [] };
|
const base = { standing: [], facility: null, modifiers: [], enhancements: [] };
|
||||||
|
|
||||||
if (kind.kind === 'track') {
|
if (kind.kind === 'track') {
|
||||||
const geometry = kind.geometry as TrackGeometry;
|
const geometry = kind.geometry as TrackGeometry;
|
||||||
@@ -1117,6 +1363,122 @@ function adjacentFacilityCoord(area: OfficeArea, coord: GridCoord): GridCoord |
|
|||||||
return null;
|
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?
|
* Q4 — would building `kind` here conflict with something already in the district?
|
||||||
* The relation is symmetric, so checking either direction is enough.
|
* The relation is symmetric, so checking either direction is enough.
|
||||||
|
|||||||
+128
-17
@@ -145,13 +145,23 @@ export type IndustryProfile = {
|
|||||||
copies: number;
|
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[] = [
|
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: '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: 2 },
|
{ 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: 1 },
|
{ 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: 2 },
|
{ 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: 1 },
|
{ 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: 1 },
|
{ 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. */
|
/** Legacy alias; the engine still reads FREIGHT_PROFILES in places. */
|
||||||
@@ -381,6 +391,8 @@ export function crossingStages(
|
|||||||
kind: MainlineKind,
|
kind: MainlineKind,
|
||||||
trainSpeed: TrainSpeed,
|
trainSpeed: TrainSpeed,
|
||||||
carriesPassengers: boolean,
|
carriesPassengers: boolean,
|
||||||
|
modifiers: readonly string[] = [],
|
||||||
|
direction: Direction = 'east',
|
||||||
): number {
|
): number {
|
||||||
const profile = MAINLINE_PROFILES.find((m) => m.kind === kind);
|
const profile = MAINLINE_PROFILES.find((m) => m.kind === kind);
|
||||||
if (!profile) throw new Error(`unknown mainline card: ${kind}`);
|
if (!profile) throw new Error(`unknown mainline card: ${kind}`);
|
||||||
@@ -394,14 +406,66 @@ export function crossingStages(
|
|||||||
mph = carriesPassengers ? profile.speed.passenger : profile.speed.freight;
|
mph = carriesPassengers ? profile.speed.passenger : profile.speed.freight;
|
||||||
break;
|
break;
|
||||||
case 'grade':
|
case 'grade':
|
||||||
// Heavy Grade has no printed number; the modifier cards (Brakeman/Airbrakes/Helpers) are what
|
// Heavy Grade has no printed number; the modifier cards are what improve it, so it is a 30
|
||||||
// improve it. Treated as a 30 until those are implemented.
|
// until one is placed.
|
||||||
mph = 30;
|
mph = 30;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
const base = mph >= 60 ? 1 : 2;
|
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. */
|
/** `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.' },
|
{ 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[] = [
|
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: '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.' },
|
{ 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 };
|
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[] = [
|
export const ROLLING_STOCK_SUPPLY: readonly StockSupply[] = [
|
||||||
{ type: 'coach', loaded: 8, empty: 8 },
|
{ type: 'coach', loaded: 8, empty: 8 },
|
||||||
{ type: 'boxcar', loaded: 6, empty: 6 },
|
{ type: 'boxcar', loaded: 10, empty: 10 },
|
||||||
{ type: 'hopper', loaded: 6, empty: 6 },
|
{ type: 'hopper', loaded: 8, empty: 8 },
|
||||||
{ type: 'reefer', loaded: 4, empty: 4 },
|
{ type: 'reefer', loaded: 5, empty: 5 },
|
||||||
{ type: 'tank', loaded: 4, empty: 4 },
|
{ type: 'tank', loaded: 6, empty: 6 },
|
||||||
{ type: 'caboose', loaded: 6, empty: 0 },
|
{ 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()
|
export const SOLITAIRE_DECK_SIZE = deckComposition()
|
||||||
.filter((c) => !isOpponentOnly(c.category))
|
.filter((c) => !isOpponentOnly(c.category))
|
||||||
.reduce((n, c) => n + c.count, 0);
|
.reduce((n, c) => n + c.count, 0);
|
||||||
|
|||||||
@@ -24,9 +24,14 @@ export type GameEvent =
|
|||||||
| { type: 'trayMoved'; trayId: TrayId; from: GridCoord; to: GridCoord; movesRemaining: number }
|
| { type: 'trayMoved'; trayId: TrayId; from: GridCoord; to: GridCoord; movesRemaining: number }
|
||||||
| { type: 'carsCoupled'; trayId: TrayId; at: GridCoord; stock: RollingStock[] }
|
| { type: 'carsCoupled'; trayId: TrayId; at: GridCoord; stock: RollingStock[] }
|
||||||
| { type: 'carsDropped'; trayId: TrayId; at: GridCoord; stock: RollingStock[] }
|
| { type: 'carsDropped'; trayId: TrayId; at: GridCoord; stock: RollingStock[] }
|
||||||
|
| { type: 'consistSorted'; trayId: TrayId; at: GridCoord; before: RollingStock[]; after: RollingStock[] }
|
||||||
| { type: 'cardDrawn'; player: PlayerIndex; source: 'homeOffice' | 'department'; slot?: number; cardId: CardId }
|
| { 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. */
|
/** `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: '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: 'officeUpgraded'; player: PlayerIndex; from: OfficeTier; to: OfficeTier }
|
||||||
| { type: 'cardDiscarded'; player: PlayerIndex; cardId: CardId; toSlot: number }
|
| { type: 'cardDiscarded'; player: PlayerIndex; cardId: CardId; toSlot: number }
|
||||||
| { type: 'deckReshuffled' }
|
| { 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,
|
* 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.
|
* 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: 'extraQueued'; player: PlayerIndex; trainNumber: number }
|
||||||
| { type: 'secondSectionOrdered'; player: PlayerIndex; trainNumber: number }
|
| { type: 'secondSectionOrdered'; player: PlayerIndex; trainNumber: number }
|
||||||
| { type: 'trainMadeUp'; trainNumber: number; isExtra: boolean; at: string; direction: string }
|
| { type: 'trainMadeUp'; trainNumber: number; isExtra: boolean; at: string; direction: string }
|
||||||
| { type: 'trainHeld'; trainNumber: number; reason: string }
|
| { type: 'trainHeld'; trainNumber: number; reason: string }
|
||||||
| { type: 'trainHighballed'; trainNumber: number; from: string; to: string }
|
| { type: 'trainHighballed'; trainNumber: number; from: string; to: string }
|
||||||
| { type: 'trainArrived'; trainNumber: number; consist: RollingStock[]; office: string }
|
| { type: 'trainArrived'; trainNumber: number; consist: RollingStock[]; office: string }
|
||||||
|
| { type: 'trainDiverted'; trainNumber: number; to: string; reason: string }
|
||||||
| { type: 'trainCompleted'; trainNumber: number; consist: RollingStock[] }
|
| { type: 'trainCompleted'; trainNumber: number; consist: RollingStock[] }
|
||||||
| { type: 'carPlacedOnTrain'; player: PlayerIndex; trayId: TrayId; stock: RollingStock }
|
| { type: 'carPlacedOnTrain'; player: PlayerIndex; trayId: TrayId; stock: RollingStock }
|
||||||
| { type: 'carPassed'; player: PlayerIndex; trayId: TrayId }
|
| { type: 'carPassed'; player: PlayerIndex; trayId: TrayId }
|
||||||
|
| { type: 'dispatchBonusUsed'; key: string; bonus: number; trainNumber: number; againstTrain: number }
|
||||||
| { type: 'clearanceRequested'; trainId: TrayId; occupiedBy: TrayId }
|
| { type: 'clearanceRequested'; trainId: TrayId; occupiedBy: TrayId }
|
||||||
| { type: 'clearanceGiven'; trainId: TrayId; allow: boolean }
|
| { type: 'clearanceGiven'; trainId: TrayId; allow: boolean }
|
||||||
// -- load / unload
|
// -- load / unload
|
||||||
|
|||||||
+34
-1
@@ -4,7 +4,7 @@
|
|||||||
* An intent is a PROPOSAL. It may be rejected. Contrast with an event (events.ts), which is a fact.
|
* 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';
|
import type { CardId, GridCoord, PlayerIndex, TrayId } from './state.ts';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -18,6 +18,12 @@ export type Intent =
|
|||||||
// -- switch (§6.1, Appendix A)
|
// -- switch (§6.1, Appendix A)
|
||||||
| { type: 'switch.move'; trayId: TrayId; to: GridCoord; reverse: boolean }
|
| { type: 'switch.move'; trayId: TrayId; to: GridCoord; reverse: boolean }
|
||||||
| { type: 'switch.dropCars'; trayId: TrayId; count: number }
|
| { 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' }
|
| { type: 'switch.end' }
|
||||||
// -- draw (§6.2)
|
// -- draw (§6.2)
|
||||||
| { type: 'draw.fromHomeOffice' }
|
| { type: 'draw.fromHomeOffice' }
|
||||||
@@ -25,6 +31,14 @@ export type Intent =
|
|||||||
/** `variant` indexes `variantsFor(geometry)` — Gap 11: orientation is chosen on placement. */
|
/** `variant` indexes `variantsFor(geometry)` — Gap 11: orientation is chosen on placement. */
|
||||||
| { type: 'card.play'; cardId: CardId; placement?: GridCoord; variant?: number }
|
| { type: 'card.play'; cardId: CardId; placement?: GridCoord; variant?: number }
|
||||||
| { type: 'card.discard'; cardId: CardId; toSlot: 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' }
|
| { type: 'draw.end' }
|
||||||
// -- freight agent (§6.3)
|
// -- freight agent (§6.3)
|
||||||
| { type: 'freightAgent.stockOutbound'; at: GridCoord; carType: CarType }
|
| { type: 'freightAgent.stockOutbound'; at: GridCoord; carType: CarType }
|
||||||
@@ -37,6 +51,21 @@ export type Intent =
|
|||||||
| { type: 'newTrain.secondSection'; trainNumber: number }
|
| { type: 'newTrain.secondSection'; trainNumber: number }
|
||||||
// -- Mainline Phase (§8.1) — the Superintendent's clearance ruling
|
// -- Mainline Phase (§8.1) — the Superintendent's clearance ruling
|
||||||
| { type: 'mainline.clearance'; allow: boolean }
|
| { 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' }
|
| { type: 'redFlag.play' }
|
||||||
// -- Load/Unload Phase (§9)
|
// -- Load/Unload Phase (§9)
|
||||||
| { type: 'porter.board'; at: GridCoord }
|
| { type: 'porter.board'; at: GridCoord }
|
||||||
@@ -83,6 +112,10 @@ export type RejectionCode =
|
|||||||
| 'NOT_UPGRADEABLE'
|
| 'NOT_UPGRADEABLE'
|
||||||
| 'FACILITY_LOCKED'
|
| 'FACILITY_LOCKED'
|
||||||
| 'NOT_IMPLEMENTED'
|
| 'NOT_IMPLEMENTED'
|
||||||
|
| 'WRONG_INTENT'
|
||||||
|
| 'NOT_A_GRADE'
|
||||||
|
| 'TRAIN_ON_CARD'
|
||||||
|
| 'CONSIST_ORDER'
|
||||||
| 'NO_TRAIN_AT_OFFICE';
|
| 'NO_TRAIN_AT_OFFICE';
|
||||||
|
|
||||||
export type Rejection = { code: RejectionCode; message: string };
|
export type Rejection = { code: RejectionCode; message: string };
|
||||||
|
|||||||
+68
-2
@@ -12,7 +12,7 @@
|
|||||||
* If you find yourself writing a rule here, it belongs in apply.ts.
|
* 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 { check, areaOf, destinationsFor } from './apply.ts';
|
||||||
import type { Intent } from './intents.ts';
|
import type { Intent } from './intents.ts';
|
||||||
import type { GameState, GridCoord, PlayerIndex } from './state.ts';
|
import type { GameState, GridCoord, PlayerIndex } from './state.ts';
|
||||||
@@ -56,6 +56,14 @@ function candidates(s: GameState, player: PlayerIndex): Intent[] {
|
|||||||
break;
|
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' });
|
out.push({ type: 'redFlag.play' });
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
@@ -82,6 +90,38 @@ function localOpsCandidates(s: GameState, player: PlayerIndex): Intent[] {
|
|||||||
for (let n = 1; n <= tray.consist.length; n++) {
|
for (let n = 1; n <= tray.consist.length; n++) {
|
||||||
out.push({ type: 'switch.dropCars', trayId, count: 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' });
|
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 });
|
for (let slot = 0; slot < 3; slot++) out.push({ type: 'draw.fromDepartment', slot });
|
||||||
|
|
||||||
const placements = placementCandidates(s, player);
|
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) ?? []) {
|
for (const cardId of s.decks.hands.get(player) ?? []) {
|
||||||
out.push({ type: 'card.play', cardId });
|
out.push({ type: 'card.play', cardId });
|
||||||
// Gap 11 — orientation is chosen on placement, so every rotation is a distinct candidate.
|
// 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.
|
// 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++) {
|
for (let variant = 0; variant < 6; variant++) {
|
||||||
out.push({ type: 'card.play', cardId, placement, variant });
|
out.push({ type: 'card.play', cardId, placement, variant });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (let slot = 0; slot < 3; slot++) out.push({ type: 'card.discard', cardId, toSlot: slot });
|
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' });
|
out.push({ type: 'draw.end' });
|
||||||
|
|
||||||
// -- freight agent (§6.3)
|
// -- freight agent (§6.3)
|
||||||
|
|||||||
+16
-1
@@ -17,6 +17,7 @@ import {
|
|||||||
MODIFIER_PROFILES,
|
MODIFIER_PROFILES,
|
||||||
OFFICE_PROFILES,
|
OFFICE_PROFILES,
|
||||||
MAINLINE_PROFILES,
|
MAINLINE_PROFILES,
|
||||||
|
TRACK_SUPPLY,
|
||||||
ROLLING_STOCK_SUPPLY,
|
ROLLING_STOCK_SUPPLY,
|
||||||
STAGES_PER_DAY,
|
STAGES_PER_DAY,
|
||||||
TIMETABLED_TRAINS,
|
TIMETABLED_TRAINS,
|
||||||
@@ -122,6 +123,7 @@ function buildOfficeArea(owner: PlayerIndex): OfficeArea {
|
|||||||
standing: [],
|
standing: [],
|
||||||
facility: buildPassengerFacility('whistlePost'),
|
facility: buildPassengerFacility('whistlePost'),
|
||||||
modifiers: [],
|
modifiers: [],
|
||||||
|
enhancements: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
const limitsCard = (): TrackCard => ({
|
const limitsCard = (): TrackCard => ({
|
||||||
@@ -130,6 +132,7 @@ function buildOfficeArea(owner: PlayerIndex): OfficeArea {
|
|||||||
standing: [],
|
standing: [],
|
||||||
facility: null,
|
facility: null,
|
||||||
modifiers: [],
|
modifiers: [],
|
||||||
|
enhancements: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
const grid = new Map<string, TrackCard>();
|
const grid = new Map<string, TrackCard>();
|
||||||
@@ -137,7 +140,19 @@ function buildOfficeArea(owner: PlayerIndex): OfficeArea {
|
|||||||
grid.set(coordKey(limitsWest), limitsCard());
|
grid.set(coordKey(limitsWest), limitsCard());
|
||||||
grid.set(coordKey(limitsEast), 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
@@ -73,7 +73,9 @@ export type CardGeometry =
|
|||||||
| { kind: 'limits' }
|
| { kind: 'limits' }
|
||||||
| { kind: 'facility'; facility: FreightKind; axis?: TrackAxis }
|
| { kind: 'facility'; facility: FreightKind; axis?: TrackAxis }
|
||||||
/** Not track — a Modifier sits beside a Facility and raises its capacity (§9). */
|
/** 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 = {
|
export type TrackCard = {
|
||||||
geometry: CardGeometry;
|
geometry: CardGeometry;
|
||||||
@@ -86,6 +88,8 @@ export type TrackCard = {
|
|||||||
standing: RollingStock[];
|
standing: RollingStock[];
|
||||||
facility: Facility | null;
|
facility: Facility | null;
|
||||||
modifiers: ModifierKind[];
|
modifiers: ModifierKind[];
|
||||||
|
/** Enhancement cards laid on this card (§7 of implications.md). */
|
||||||
|
enhancements: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type OfficeArea = {
|
export type OfficeArea = {
|
||||||
@@ -99,6 +103,18 @@ export type OfficeArea = {
|
|||||||
limitsEast: GridCoord;
|
limitsEast: GridCoord;
|
||||||
/** Trays holding at the Office. Length must never exceed the tier's A/D track count. */
|
/** Trays holding at the Office. Length must never exceed the tier's A/D track count. */
|
||||||
adOccupancy: TrayId[];
|
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 =
|
export type DivisionNode =
|
||||||
| { kind: 'divisionPoint'; side: Direction; holding: TrayId[] }
|
| { 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 };
|
| { kind: 'office'; owner: PlayerIndex };
|
||||||
|
|
||||||
/** Ordered west to east. For N players: N Office nodes and N+1 Mainline cards. */
|
/** 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;
|
option: 'switch' | 'draw' | 'freightAgent' | null;
|
||||||
movesRemaining: number;
|
movesRemaining: number;
|
||||||
drawnThisTurn: boolean;
|
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;
|
freightAgentUsed: boolean;
|
||||||
/** Set when the actor finishes; the phase driver then moves to the next player. */
|
/** Set when the actor finishes; the phase driver then moves to the next player. */
|
||||||
done: boolean;
|
done: boolean;
|
||||||
@@ -338,6 +368,7 @@ export function freshTurn(moves: number): TurnState {
|
|||||||
option: null,
|
option: null,
|
||||||
movesRemaining: moves,
|
movesRemaining: moves,
|
||||||
drawnThisTurn: false,
|
drawnThisTurn: false,
|
||||||
|
laidThisTurn: false,
|
||||||
freightAgentUsed: false,
|
freightAgentUsed: false,
|
||||||
done: false,
|
done: false,
|
||||||
};
|
};
|
||||||
|
|||||||
+2
-1
@@ -79,8 +79,9 @@ type PortPair = readonly [Port, Port];
|
|||||||
*/
|
*/
|
||||||
function connectionsFor(card: TrackCard): readonly PortPair[] {
|
function connectionsFor(card: TrackCard): readonly PortPair[] {
|
||||||
switch (card.geometry.kind) {
|
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 'modifier':
|
||||||
|
case 'spaceUse':
|
||||||
return [];
|
return [];
|
||||||
case 'limits':
|
case 'limits':
|
||||||
return [['e', 'w']];
|
return [['e', 'w']];
|
||||||
|
|||||||
+181
@@ -63,6 +63,11 @@ export const developerBot: BotPolicy = {
|
|||||||
const clearance = ruleOnClearance(options);
|
const clearance = ruleOnClearance(options);
|
||||||
if (clearance) return clearance;
|
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.
|
// --- 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
|
// 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.
|
// whose value compounds. Playing one needs the draw option.
|
||||||
if (can('draw') && hand.some((id) => isTrainCard(s, id))) return can('draw')!;
|
if (can('draw') && hand.some((id) => isTrainCard(s, id))) return can('draw')!;
|
||||||
|
|
||||||
|
// A Mainline modifier is worth the option too: Realignment permanently converts a 30 card into a
|
||||||
|
// 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
|
// 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
|
// 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
|
// 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;
|
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 {
|
function isTrainCard(s: GameState, cardId: string): boolean {
|
||||||
const k = s.cards.get(cardId)?.kind.kind;
|
const k = s.cards.get(cardId)?.kind.kind;
|
||||||
return k === 'timetabledTrain' || k === 'extraTrain';
|
return k === 'timetabledTrain' || k === 'extraTrain';
|
||||||
@@ -213,6 +233,68 @@ function needsClearing(s: GameState, player: PlayerIndex): boolean {
|
|||||||
return false;
|
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. */
|
/** Once an option is chosen, work it to a sensible conclusion. */
|
||||||
function followThrough(s: GameState, player: PlayerIndex, options: Intent[]): Intent {
|
function followThrough(s: GameState, player: PlayerIndex, options: Intent[]): Intent {
|
||||||
switch (s.turn.option) {
|
switch (s.turn.option) {
|
||||||
@@ -238,6 +320,32 @@ function followThrough(s: GameState, player: PlayerIndex, options: Intent[]): In
|
|||||||
);
|
);
|
||||||
if (train) return train;
|
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.
|
// Then real development: a card actually laid into the grid.
|
||||||
const placed = options.find((i) => i.type === 'card.play' && i.placement !== undefined);
|
const placed = options.find((i) => i.type === 'card.play' && i.placement !== undefined);
|
||||||
if (placed) return placed;
|
if (placed) return placed;
|
||||||
@@ -272,6 +380,34 @@ function followThrough(s: GameState, player: PlayerIndex, options: Intent[]): In
|
|||||||
const here = trayLocation(s, player);
|
const here = trayLocation(s, player);
|
||||||
const endCar = tray && tray.consist.length > 0 ? tray.consist[tray.consist.length - 1]! : null;
|
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) {
|
if (endCar && here) {
|
||||||
const hereFacility = areaOf(s, player).grid.get(`${here.row},${here.col}`)?.facility ?? null;
|
const hereFacility = areaOf(s, player).grid.get(`${here.row},${here.col}`)?.facility ?? null;
|
||||||
if (hereFacility && facilityWants(hereFacility, endCar)) {
|
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),
|
wanted.some((t) => t.row === i.to.row && t.col === i.to.col),
|
||||||
);
|
);
|
||||||
if (toward) return toward;
|
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');
|
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;
|
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
|
// 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.
|
// hopper stranded on the Running Track that the crew kept driving past.
|
||||||
if (tray.consist.length < MAX_CONSIST) {
|
if (tray.consist.length < MAX_CONSIST) {
|
||||||
|
|||||||
@@ -124,6 +124,12 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
|
|||||||
where: e.at,
|
where: e.at,
|
||||||
text: `Coupled ${e.stock.length} car(s) at ${at(e.at)}: ${carsLabel(e.stock)}`,
|
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':
|
case 'carsDropped':
|
||||||
return {
|
return {
|
||||||
tone: 'plain',
|
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)} onto ${at(e.placement)}`
|
||||||
: `Played ${card(e.cardId)}`,
|
: `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':
|
case 'officeUpgraded':
|
||||||
return { tone: 'good', text: `OFFICE UPGRADED — ${e.from} → ${e.to}` };
|
return { tone: 'good', text: `OFFICE UPGRADED — ${e.from} → ${e.to}` };
|
||||||
case 'cardDiscarded':
|
case 'cardDiscarded':
|
||||||
@@ -169,6 +199,12 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
|
|||||||
tone: 'good',
|
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`,
|
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':
|
case 'secondSectionOrdered':
|
||||||
return {
|
return {
|
||||||
tone: 'bad',
|
tone: 'bad',
|
||||||
@@ -194,6 +230,11 @@ export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
|
|||||||
tone: 'plain',
|
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`,
|
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':
|
case 'trainCompleted':
|
||||||
return {
|
return {
|
||||||
tone: 'quiet',
|
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` };
|
return { tone: 'plain', text: `Added a ${carLabel(e.stock)} to the train being made up` };
|
||||||
case 'carPassed':
|
case 'carPassed':
|
||||||
return { tone: 'quiet', text: 'Passed — no suitable car in the Division Yard' };
|
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':
|
case 'clearanceRequested':
|
||||||
return {
|
return {
|
||||||
tone: 'bad',
|
tone: 'bad',
|
||||||
|
|||||||
@@ -154,6 +154,7 @@ function snapshot(
|
|||||||
else if (g.kind === 'limits') label = 'Limits';
|
else if (g.kind === 'limits') label = 'Limits';
|
||||||
else if (g.kind === 'facility') label = FACILITY_NAMES[g.facility] ?? g.facility;
|
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 === '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;
|
else label = g.geometry === 'sharpCurved' ? 'sharp curve' : g.geometry;
|
||||||
|
|
||||||
const fv = facilityView(card as never, officeProfile(area.tier).name);
|
const fv = facilityView(card as never, officeProfile(area.tier).name);
|
||||||
|
|||||||
@@ -220,6 +220,9 @@ const EXPECTED_EVENTS = [
|
|||||||
'carPlacedOnTrain',
|
'carPlacedOnTrain',
|
||||||
'trayMoved',
|
'trayMoved',
|
||||||
'carsCoupled',
|
'carsCoupled',
|
||||||
|
'mainlineModified',
|
||||||
|
'redFlagsSet',
|
||||||
|
'flyingSwitch',
|
||||||
'carsDropped',
|
'carsDropped',
|
||||||
'cardDrawn',
|
'cardDrawn',
|
||||||
'cardPlayed',
|
'cardPlayed',
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import assert from 'node:assert/strict';
|
|||||||
|
|
||||||
import { advance, pump } from '../src/engine/advance.ts';
|
import { advance, pump } from '../src/engine/advance.ts';
|
||||||
import { applyIntent } from '../src/engine/apply.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 { legalActions } from '../src/engine/legal.ts';
|
||||||
import { createGame } from '../src/engine/setup.ts';
|
import { createGame } from '../src/engine/setup.ts';
|
||||||
import type { GameConfig, GameState } from '../src/engine/state.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', () => {
|
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);
|
const s = game(88);
|
||||||
playToCompletion(s, 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');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ const straight = (standing: TrackCard['standing'] = []): TrackCard => ({
|
|||||||
standing,
|
standing,
|
||||||
facility: null,
|
facility: null,
|
||||||
modifiers: [],
|
modifiers: [],
|
||||||
|
enhancements: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -325,6 +326,7 @@ describe('Freight Agent operations (§6.3)', () => {
|
|||||||
usedThisStage: { laborers: 0, porters: 0 },
|
usedThisStage: { laborers: 0, porters: 0 },
|
||||||
},
|
},
|
||||||
modifiers: [],
|
modifiers: [],
|
||||||
|
enhancements: [],
|
||||||
});
|
});
|
||||||
return coord;
|
return coord;
|
||||||
}
|
}
|
||||||
@@ -421,6 +423,7 @@ describe('Load/Unload: the four-action freight pipeline (§9.3)', () => {
|
|||||||
usedThisStage: { laborers: 0, porters: 0 },
|
usedThisStage: { laborers: 0, porters: 0 },
|
||||||
},
|
},
|
||||||
modifiers: [],
|
modifiers: [],
|
||||||
|
enhancements: [],
|
||||||
});
|
});
|
||||||
s.clock.phase = 'loadUnload';
|
s.clock.phase = 'loadUnload';
|
||||||
return coord;
|
return coord;
|
||||||
@@ -561,6 +564,7 @@ describe('legalActions shares its rules with apply (component 6)', () => {
|
|||||||
usedThisStage: { laborers: 0, porters: 0 },
|
usedThisStage: { laborers: 0, porters: 0 },
|
||||||
},
|
},
|
||||||
modifiers: [],
|
modifiers: [],
|
||||||
|
enhancements: [],
|
||||||
});
|
});
|
||||||
assert.deepEqual(chooseOptions(s), ['draw', 'freightAgent', 'switch']);
|
assert.deepEqual(chooseOptions(s), ['draw', 'freightAgent', 'switch']);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
@@ -49,9 +49,10 @@ const newSolitaireGame = (seed = 1234) =>
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
describe('card catalogue (component 1)', () => {
|
describe('card catalogue (component 1)', () => {
|
||||||
it('composes the 115-card deck from the design', () => {
|
it('composes the 133-card deck from the design', () => {
|
||||||
// Transcribed from docs/Deck cards2.xlsx; the sheet's own total is 115.
|
// Transcribed from docs/Deck cards2.xlsx, whose own total is 115 — plus the 18 extra industry
|
||||||
assert.equal(DECK_SIZE, 115);
|
// 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);
|
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]));
|
const byCategory = Object.fromEntries(deckComposition().map((c) => [c.category, c.count]));
|
||||||
assert.deepEqual(byCategory, {
|
assert.deepEqual(byCategory, {
|
||||||
office: 7,
|
office: 7,
|
||||||
industry: 9,
|
// 27, not the sheet's 9 — Gap 12 industry density; see INDUSTRY_PROFILES.
|
||||||
|
industry: 27,
|
||||||
modifier: 23,
|
modifier: 23,
|
||||||
train: 22,
|
train: 22,
|
||||||
spaceUse: 12,
|
spaceUse: 12,
|
||||||
@@ -72,13 +74,13 @@ describe('card catalogue (component 1)', () => {
|
|||||||
|
|
||||||
it('removes opponent-directed cards from a solitaire deck', () => {
|
it('removes opponent-directed cards from a solitaire deck', () => {
|
||||||
// Q6 — Space-use and Action cards can only be played AT another player, so in a one-player
|
// Q6 — Space-use and Action cards can only be played AT another player, so in a one-player
|
||||||
// game they would be 22 of 115 draws (19%) that do nothing.
|
// game they would be 22 of 133 draws (17%) that do nothing.
|
||||||
assert.equal(SOLITAIRE_DECK_SIZE, 93);
|
assert.equal(SOLITAIRE_DECK_SIZE, 111);
|
||||||
const solo = buildDeck('solitaire');
|
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'));
|
assert.ok(!solo.some((c) => c.kind.kind === 'spaceUse' || c.kind.kind === 'action'));
|
||||||
// A competitive deck keeps them.
|
// A competitive deck keeps them.
|
||||||
assert.equal(buildDeck('competitive').length, 115);
|
assert.equal(buildDeck('competitive').length, 133);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps track OUT of the deck, as a per-player supply', () => {
|
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);
|
assert.equal(nextOfficeTier('terminal'), null);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('supplies 62 rolling stock pieces', () => {
|
it('supplies the full rolling stock roster', () => {
|
||||||
assert.equal(TOTAL_ROLLING_STOCK, 62);
|
// 80 pieces after the Gap 12 supply scale-up. Asserted against the constant rather than a
|
||||||
assert.equal(buildRollingStock().length, 62);
|
// 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', () => {
|
it('never demands more cars than the supply can furnish', () => {
|
||||||
@@ -340,7 +344,7 @@ describe('game setup (component 2)', () => {
|
|||||||
...g.decks.salvageYard,
|
...g.decks.salvageYard,
|
||||||
...[...g.decks.hands.values()].flat(),
|
...[...g.decks.hands.values()].flat(),
|
||||||
];
|
];
|
||||||
// A solitaire deck omits the 22 opponent-directed cards (Q6), so it holds 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(all.length, SOLITAIRE_DECK_SIZE, 'cards lost or duplicated');
|
||||||
assert.equal(new Set(all).size, SOLITAIRE_DECK_SIZE, 'duplicate card ids');
|
assert.equal(new Set(all).size, SOLITAIRE_DECK_SIZE, 'duplicate card ids');
|
||||||
});
|
});
|
||||||
@@ -357,9 +361,9 @@ describe('game setup (component 2)', () => {
|
|||||||
assert.equal(newSolitaireGame().freeTrays.length, 4);
|
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();
|
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);
|
assert.equal(g.yards.classificationYard.length, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ const straight = (standing: RollingStock[] = []): TrackCard => ({
|
|||||||
standing,
|
standing,
|
||||||
facility: null,
|
facility: null,
|
||||||
modifiers: [],
|
modifiers: [],
|
||||||
|
enhancements: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
/** A straight rotated to run north-south. Needed to meet the Office's junction stubs (Gap 11). */
|
/** 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,
|
standing,
|
||||||
facility: null,
|
facility: null,
|
||||||
modifiers: [],
|
modifiers: [],
|
||||||
|
enhancements: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
const turnout = (o?: TurnoutOrientation): TrackCard => ({
|
const turnout = (o?: TurnoutOrientation): TrackCard => ({
|
||||||
@@ -55,6 +57,7 @@ const turnout = (o?: TurnoutOrientation): TrackCard => ({
|
|||||||
standing: [],
|
standing: [],
|
||||||
facility: null,
|
facility: null,
|
||||||
modifiers: [],
|
modifiers: [],
|
||||||
|
enhancements: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
const officeCard = (): TrackCard => ({
|
const officeCard = (): TrackCard => ({
|
||||||
@@ -63,6 +66,7 @@ const officeCard = (): TrackCard => ({
|
|||||||
standing: [],
|
standing: [],
|
||||||
facility: null,
|
facility: null,
|
||||||
modifiers: [],
|
modifiers: [],
|
||||||
|
enhancements: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
const lockedFacility = (): TrackCard => ({
|
const lockedFacility = (): TrackCard => ({
|
||||||
@@ -84,6 +88,7 @@ const lockedFacility = (): TrackCard => ({
|
|||||||
usedThisStage: { laborers: 0, porters: 0 },
|
usedThisStage: { laborers: 0, porters: 0 },
|
||||||
},
|
},
|
||||||
modifiers: [],
|
modifiers: [],
|
||||||
|
enhancements: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
const car = (): RollingStock => ({ type: 'boxcar', loaded: false });
|
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 },
|
limitsWest: { row: officeCoord.row, col: officeCoord.col - 2 },
|
||||||
limitsEast: { row: officeCoord.row, col: officeCoord.col + 2 },
|
limitsEast: { row: officeCoord.row, col: officeCoord.col + 2 },
|
||||||
adOccupancy: [],
|
adOccupancy: [],
|
||||||
|
heldAtLimits: [],
|
||||||
|
dispatchUsedToday: [],
|
||||||
|
trackSupply: new Map(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,6 +167,7 @@ describe('ports and geometry', () => {
|
|||||||
standing: [],
|
standing: [],
|
||||||
facility: null,
|
facility: null,
|
||||||
modifiers: [],
|
modifiers: [],
|
||||||
|
enhancements: [],
|
||||||
};
|
};
|
||||||
assert.deepEqual(exitsFrom(r, 's'), ['w']);
|
assert.deepEqual(exitsFrom(r, 's'), ['w']);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user