Implement Enhancements, Mainline modifiers and Maneuvers; fix unplayable track
This commit is contained in:
+114
-2
@@ -19,6 +19,7 @@
|
||||
import {
|
||||
COLLISION_PENALTY,
|
||||
MAINLINE_PROFILES,
|
||||
enhancementRule,
|
||||
consistSize,
|
||||
crossingStages,
|
||||
trainProfile,
|
||||
@@ -349,11 +350,57 @@ function enterMainline(
|
||||
): void {
|
||||
const profile = trainProfile(tray.trainNumber ?? 0, tray.trainIsExtra);
|
||||
const carriesPassengers = tray.consist.some((c) => c.type === 'coach');
|
||||
const stages = crossingStages(node.card, profile?.speed ?? 'slow', carriesPassengers);
|
||||
const stages = crossingStages(
|
||||
node.card,
|
||||
profile?.speed ?? 'slow',
|
||||
carriesPassengers,
|
||||
node.modifiers ?? [],
|
||||
tray.direction,
|
||||
);
|
||||
node.transits.push({ tray: id, stagesRemaining: stages, direction: tray.direction });
|
||||
tray.position = { at: 'mainline', index };
|
||||
}
|
||||
|
||||
/**
|
||||
* Telegraph / Telephone / Radio — "once a day, when dispatching facing trains, add +N to the other
|
||||
* train's number". Spends the best available device the owning player has, once per Day each.
|
||||
*
|
||||
* Returns the bonus applied to the opposing train's number (0 if none was available or needed).
|
||||
*/
|
||||
function spendDispatchBonus(
|
||||
s: GameState,
|
||||
tray: CrewTray,
|
||||
other: CrewTray,
|
||||
events: GameEvent[],
|
||||
): number {
|
||||
const mine = tray.trainNumber ?? 99;
|
||||
const theirs = other.trainNumber ?? 99;
|
||||
|
||||
const area = s.officeAreas.get(s.clock.superintendent);
|
||||
if (!area) return 0;
|
||||
|
||||
// Best device first — Radio (+12) beats Telephone (+8) beats Telegraph (+4).
|
||||
for (const key of ['radio', 'telephone', 'telegraph'] as const) {
|
||||
if (area.dispatchUsedToday.includes(key)) continue;
|
||||
const present = [...area.grid.values()].some((c) => c.enhancements.includes(key));
|
||||
if (!present) continue;
|
||||
const bonus = enhancementRule(key)?.dispatchBonus ?? 0;
|
||||
// Spend it only if it actually wins the meet — the device makes the OTHER train count as
|
||||
// junior, so ours may proceed.
|
||||
if (mine >= theirs + bonus) continue;
|
||||
area.dispatchUsedToday.push(key);
|
||||
events.push({
|
||||
type: 'dispatchBonusUsed',
|
||||
key,
|
||||
bonus,
|
||||
trainNumber: tray.trainNumber ?? 0,
|
||||
againstTrain: other.trainNumber ?? 0,
|
||||
});
|
||||
return bonus;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** Q3 — an expedited train does not spend a Stage standing at the Office. */
|
||||
function isExpedited(tray: CrewTray): boolean {
|
||||
return trainProfile(tray.trainNumber ?? 0, tray.trainIsExtra)?.rules.expedite === true;
|
||||
@@ -449,6 +496,8 @@ function moveTrain(
|
||||
const target = index + dir;
|
||||
const dest = s.division.nodes[target];
|
||||
node.transits = node.transits.filter((t) => t.tray !== id);
|
||||
// Red Flags protect a train while it is stopped here; once it rolls, the flags come in.
|
||||
if (node.redFlagged) node.redFlagged = node.redFlagged.filter((t) => t !== id);
|
||||
|
||||
if (!dest) return 'held';
|
||||
|
||||
@@ -503,7 +552,26 @@ function evaluateClearance(
|
||||
const otherTray = s.trays.get(other);
|
||||
if (!otherTray) continue;
|
||||
|
||||
if (otherTray.direction !== tray.direction) return 'blocked';
|
||||
if (otherTray.direction !== tray.direction) {
|
||||
// §8.1 — a train moving TOWARDS the considered train is an absolute bar. That stands.
|
||||
//
|
||||
// The exception is dispatching technology. Telegraph/Telephone/Radio exist precisely so the
|
||||
// Superintendent can arrange a meet with a facing train: "once a day, when dispatching
|
||||
// facing trains, add +4/+8/+12 to the other train's number". Without a device there is no
|
||||
// way to pass the order, so the train simply holds.
|
||||
if (spendDispatchBonus(s, tray, otherTray, events) > 0) return 'clear';
|
||||
return 'blocked';
|
||||
}
|
||||
|
||||
// Red Flags — "a stopped train is prevented from being hit; the approaching train is prevented
|
||||
// from moving". Flagging is per-train rather than per-card, so it protects one specific train
|
||||
// where ABS Signals protects everything on the card.
|
||||
if ((node.redFlagged ?? []).includes(other)) return 'blocked';
|
||||
|
||||
// ABS Signals — "trains on this card will not rear-end each other; they stop short of a
|
||||
// collision". With signals in place a following train simply holds, and the Superintendent has
|
||||
// no judgment call to make. This is the amendment to Gap 2's unconditional collisions.
|
||||
if (node.absSignals) return 'blocked';
|
||||
|
||||
// Same direction — the Superintendent must rule (§8.1, fourth condition).
|
||||
s.clock.pendingDecision = { train: id, occupiedBy: other };
|
||||
@@ -526,13 +594,55 @@ function arriveAtOffice(
|
||||
): MoveOutcome {
|
||||
const area = areaOf(s, owner);
|
||||
const capacity = officeProfile(area.tier).adTracks;
|
||||
const hasEnhancement = (key: string): boolean =>
|
||||
[...area.grid.values()].some((c) => c.enhancements.includes(key));
|
||||
|
||||
// Yard Office — "an inbound train with NO COACHES that can make a single move to the yard office
|
||||
// track may arrive there, not at the Train Order Office". It sidesteps the A/D track entirely.
|
||||
const noCoaches = !tray.consist.some((c) => c.type === 'coach');
|
||||
if (noCoaches && hasEnhancement('yardOffice')) {
|
||||
for (const [key, card] of area.grid) {
|
||||
if (!card.enhancements.includes('yardOffice')) continue;
|
||||
const [row, col] = key.split(',').map(Number);
|
||||
tray.position = { at: 'grid', owner, coord: { row: row!, col: col! } };
|
||||
events.push({
|
||||
type: 'trainDiverted',
|
||||
trainNumber: tray.trainNumber ?? 0,
|
||||
to: 'the Yard Office',
|
||||
reason: 'no coaches, so it need not occupy the Train Order Office',
|
||||
});
|
||||
return 'moved';
|
||||
}
|
||||
}
|
||||
|
||||
// Gap 2d — no room at the station is a collision, and it is the local player's fault (§10).
|
||||
if (area.adOccupancy.length >= capacity) {
|
||||
// Interlocking — "may stop an inbound train on the Limit Track". Instead of an automatic
|
||||
// collision, the train is held at the Limits until an A/D track frees up. This is the designed
|
||||
// answer to the A/D overflow that Gap 2d made fatal.
|
||||
if (hasEnhancement('interlocking')) {
|
||||
area.heldAtLimits.push(id);
|
||||
events.push({
|
||||
type: 'trainDiverted',
|
||||
trainNumber: tray.trainNumber ?? 0,
|
||||
to: 'the Limits',
|
||||
reason: 'Interlocking held it clear of a full Office instead of a collision',
|
||||
});
|
||||
return 'moved';
|
||||
}
|
||||
collide(s, owner, [id], events, 'no free A/D track');
|
||||
return 'moved';
|
||||
}
|
||||
|
||||
// A train held at the Limits takes the first free A/D track before any newcomer.
|
||||
if (area.heldAtLimits.length > 0 && area.heldAtLimits[0] !== id) {
|
||||
const first = area.heldAtLimits.shift()!;
|
||||
area.adOccupancy.push(first);
|
||||
const held = s.trays.get(first);
|
||||
if (held) held.position = { at: 'grid', owner, coord: area.officeCoord };
|
||||
}
|
||||
area.heldAtLimits = area.heldAtLimits.filter((t) => t !== id);
|
||||
|
||||
// §8.3 — cars standing on the Running Track between the Limits and the Office. A train at speed
|
||||
// is not expecting them (§A.4), so this is a collision too, not a coupling.
|
||||
const officeCard = area.grid.get(coordKey(area.officeCoord));
|
||||
@@ -627,6 +737,8 @@ function shiftChange(s: GameState, events: GameEvent[]): AdvanceResult {
|
||||
}
|
||||
|
||||
if (s.clock.stage >= STAGES_PER_DAY) {
|
||||
// Telegraph/Telephone/Radio are each usable once a Day.
|
||||
for (const area of s.officeAreas.values()) area.dispatchUsedToday = [];
|
||||
s.clock.day += 1;
|
||||
s.clock.stage = 1;
|
||||
s.collisionsToday = 0;
|
||||
|
||||
+366
-4
@@ -19,15 +19,20 @@ import {
|
||||
HAND_LIMIT,
|
||||
LABORER_ACTIONS_PER_LOAD,
|
||||
MAX_CONSIST,
|
||||
REALIGNMENTS,
|
||||
enhancementRule,
|
||||
industryProfile,
|
||||
mainlineModifierRule,
|
||||
mainlineProfile,
|
||||
modifierProfile,
|
||||
nextOfficeTier,
|
||||
officeProfile,
|
||||
} from './content.ts';
|
||||
import type { CarType, FreightKind, ModifierKind, TrackGeometry } from './content.ts';
|
||||
import type { CarType, FreightKind, Hand, MainlineKind, ModifierKind, TrackGeometry } from './content.ts';
|
||||
import type { GameEvent } from './events.ts';
|
||||
import type { Intent, RejectionCode } from './intents.ts';
|
||||
import type {
|
||||
CardId,
|
||||
Facility,
|
||||
GameState,
|
||||
GridCoord,
|
||||
@@ -272,6 +277,25 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
|
||||
return canDropCarsAt(areaOf(s, player), here, i.count) ? null : 'CANNOT_DROP_HERE';
|
||||
}
|
||||
|
||||
case 'switch.sortConsist': {
|
||||
if (!inPhase(s, 'localOps')) return 'WRONG_PHASE';
|
||||
if (s.turn.option !== 'switch') return 'OPTION_NOT_CHOSEN';
|
||||
if (s.turn.movesRemaining < 1) return 'NO_MOVES_REMAINING';
|
||||
const tray = s.trays.get(i.trayId);
|
||||
if (!tray) return 'NO_SUCH_TRAY';
|
||||
const here = trayCoord(s, i.trayId);
|
||||
if (!here) return 'ILLEGAL_MOVE';
|
||||
// Only on a card carrying a Small Yard, and it costs the Move it "spends in the yard".
|
||||
const card = areaOf(s, player).grid.get(coordKey(here));
|
||||
if (!card?.enhancements.includes('smallYard')) return 'NOT_CONNECTED';
|
||||
// The order must be a permutation of the current consist.
|
||||
if (i.order.length !== tray.consist.length) return 'CONSIST_ORDER';
|
||||
const seen = new Set(i.order);
|
||||
if (seen.size !== i.order.length) return 'CONSIST_ORDER';
|
||||
if (i.order.some((n) => n < 0 || n >= tray.consist.length)) return 'CONSIST_ORDER';
|
||||
return null;
|
||||
}
|
||||
|
||||
case 'switch.end':
|
||||
if (!inPhase(s, 'localOps')) return 'WRONG_PHASE';
|
||||
return s.turn.option === 'switch' ? null : 'OPTION_NOT_CHOSEN';
|
||||
@@ -307,6 +331,78 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
|
||||
return null;
|
||||
}
|
||||
|
||||
case 'track.lay': {
|
||||
if (!inPhase(s, 'localOps')) return 'WRONG_PHASE';
|
||||
if (s.turn.option !== 'draw') return 'OPTION_NOT_CHOSEN';
|
||||
if (s.turn.laidThisTurn) return 'OPTION_ALREADY_CHOSEN';
|
||||
const key = `${i.geometry}:${i.hand}`;
|
||||
if ((areaOf(s, player).trackSupply.get(key) ?? 0) < 1) return 'NO_SUCH_CARD';
|
||||
const proto = protoTrackCard(i.geometry, i.hand, i.variant);
|
||||
if (!proto) return 'NO_PLACEMENT';
|
||||
return canPlaceAt(areaOf(s, player), i.placement, proto) ? null : 'NOT_CONNECTED';
|
||||
}
|
||||
|
||||
case 'mainline.modify': {
|
||||
if (!inPhase(s, 'localOps')) return 'WRONG_PHASE';
|
||||
if (s.turn.option !== 'draw') return 'OPTION_NOT_CHOSEN';
|
||||
const card = s.cards.get(i.cardId);
|
||||
if (!card || !(s.decks.hands.get(player) ?? []).includes(i.cardId)) return 'NO_SUCH_CARD';
|
||||
if (card.kind.kind !== 'mainlineModifier') return 'WRONG_INTENT';
|
||||
const rule = mainlineModifierRule(card.kind.key);
|
||||
if (!rule) return 'NOT_IMPLEMENTED';
|
||||
const node = s.division.nodes[i.node];
|
||||
if (!node || node.kind !== 'mainline') return 'NO_PLACEMENT';
|
||||
const on = node.modifiers ?? [];
|
||||
if (on.includes(rule.key)) return 'OPTION_ALREADY_CHOSEN';
|
||||
if (rule.gradeOnly && mainlineProfile(node.card).speed.kind !== 'grade') return 'NOT_A_GRADE';
|
||||
if (rule.requiresOnCard && !on.includes(rule.requiresOnCard)) return 'NOT_CONNECTED';
|
||||
// "Not while a train is on it" — realigning under a moving train is exactly the situation the
|
||||
// restriction exists to prevent.
|
||||
if (node.transits.length > 0) return 'TRAIN_ON_CARD';
|
||||
if (rule.key === 'realignment' && !REALIGNMENTS.some((r) => r.from === node.card)) {
|
||||
return 'NO_PLACEMENT';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
case 'maneuver.redFlags': {
|
||||
const card = s.cards.get(i.cardId);
|
||||
if (!card || !(s.decks.hands.get(player) ?? []).includes(i.cardId)) return 'NO_SUCH_CARD';
|
||||
if (card.kind.kind !== 'maneuver' || card.kind.key !== 'redFlags') return 'WRONG_INTENT';
|
||||
const tray = s.trays.get(i.trayId);
|
||||
if (!tray) return 'NO_SUCH_TRAY';
|
||||
// "A STOPPED train is prevented from being hit" — it protects a train that is standing on a
|
||||
// Mainline card, which is the only place a rear-ender can happen.
|
||||
if (tray.position.at !== 'mainline') return 'NO_PLACEMENT';
|
||||
const node = s.division.nodes[tray.position.index];
|
||||
if (!node || node.kind !== 'mainline') return 'NO_PLACEMENT';
|
||||
if ((node.redFlagged ?? []).includes(i.trayId)) return 'OPTION_ALREADY_CHOSEN';
|
||||
return null;
|
||||
}
|
||||
|
||||
case 'maneuver.flyingSwitch': {
|
||||
if (!inPhase(s, 'localOps')) return 'WRONG_PHASE';
|
||||
if (s.turn.option !== 'switch') return 'OPTION_NOT_CHOSEN';
|
||||
if (s.turn.movesRemaining < 1) return 'NO_MOVES_REMAINING';
|
||||
const card = s.cards.get(i.cardId);
|
||||
if (!card || !(s.decks.hands.get(player) ?? []).includes(i.cardId)) return 'NO_SUCH_CARD';
|
||||
if (card.kind.kind !== 'maneuver' || card.kind.key !== 'flyingSwitch') return 'WRONG_INTENT';
|
||||
const tray = s.trays.get(i.trayId);
|
||||
if (!tray) return 'NO_SUCH_TRAY';
|
||||
const here = trayCoord(s, i.trayId);
|
||||
if (!here) return 'CANNOT_DROP_HERE';
|
||||
if (i.count < 1 || i.count > tray.consist.length) return 'CONSIST_EMPTY';
|
||||
// The cut rolls into an ADJACENT industry — the engine never enters, which is the whole point
|
||||
// of the move and why it beats a normal spot.
|
||||
const adjacent =
|
||||
Math.abs(i.to.row - here.row) + Math.abs(i.to.col - here.col) === 1;
|
||||
if (!adjacent) return 'NOT_CONNECTED';
|
||||
const fsArea = areaOf(s, player);
|
||||
const target = fsArea.grid.get(coordKey(i.to));
|
||||
if (!target?.facility || target.facility.kind !== 'freight') return 'CANNOT_DROP_HERE';
|
||||
return canDropCarsAt(fsArea, i.to, i.count) ? null : 'CANNOT_DROP_HERE';
|
||||
}
|
||||
|
||||
case 'draw.end': {
|
||||
if (!inPhase(s, 'localOps')) return 'WRONG_PHASE';
|
||||
if (s.turn.option !== 'draw') return 'OPTION_NOT_CHOSEN';
|
||||
@@ -478,10 +574,29 @@ function checkPlay(
|
||||
// spots) or it does nothing at all, so placing it anywhere else is not a legal play.
|
||||
return adjacentFacilityCoord(area, placement) ? null : 'NOT_CONNECTED';
|
||||
}
|
||||
case 'spaceUse':
|
||||
case 'enhancement':
|
||||
case 'enhancement': {
|
||||
if (!placement) return 'NO_PLACEMENT';
|
||||
return checkEnhancementPlacement(s, area, card.kind.key, placement);
|
||||
}
|
||||
|
||||
case 'mainlineModifier':
|
||||
// Facing Point Locks appears in BOTH the Enhancement and Mainline-modifier lists on the sheet,
|
||||
// with the same placement and the same effect. It is one card printed twice, so the mainline
|
||||
// copy uses the enhancement's grid placement rather than going onto a Mainline card.
|
||||
if (card.kind.key === 'facingPointLocksMainline') {
|
||||
if (!placement) return 'NO_PLACEMENT';
|
||||
return checkEnhancementPlacement(s, area, 'facingPointLocks', placement);
|
||||
}
|
||||
// The rest are laid on a Mainline card, which is not a grid coordinate — see
|
||||
// `mainline.modify`.
|
||||
return 'WRONG_INTENT';
|
||||
|
||||
case 'maneuver':
|
||||
// Red Flags and Flying Switch have their own intents; Poling's effect is recorded as "TBD in
|
||||
// the source", so there is nothing to implement.
|
||||
return 'WRONG_INTENT';
|
||||
|
||||
case 'spaceUse':
|
||||
case 'action':
|
||||
// Recovered from the design but not yet implemented — see docs/rules/implications.md §7 and
|
||||
// §10. Rejecting is honest: silently accepting would make the card look playable while doing
|
||||
@@ -552,6 +667,20 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
|
||||
return [{ type: 'carsDropped', trayId: i.trayId, at: here, stock }];
|
||||
}
|
||||
|
||||
case 'switch.sortConsist': {
|
||||
const tray = s.trays.get(i.trayId)!;
|
||||
const here = trayCoord(s, i.trayId)!;
|
||||
return [
|
||||
{
|
||||
type: 'consistSorted',
|
||||
trayId: i.trayId,
|
||||
at: here,
|
||||
before: tray.consist.map((c) => ({ ...c })),
|
||||
after: i.order.map((n) => ({ ...tray.consist[n]! })),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
case 'switch.end':
|
||||
case 'draw.end':
|
||||
return [{ type: 'phaseEnded', player, phase: 'localOps' }];
|
||||
@@ -607,6 +736,14 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
|
||||
to: card.kind.tier,
|
||||
});
|
||||
}
|
||||
if (card.kind.kind === 'enhancement' && i.placement) {
|
||||
events.push({
|
||||
type: 'enhancementPlaced',
|
||||
player,
|
||||
key: card.kind.key,
|
||||
at: i.placement,
|
||||
});
|
||||
}
|
||||
if (card.kind.kind === 'extraTrain') {
|
||||
// §7 — an Extra runs once, immediately, as soon as a Crew Tray frees up. Playing one used
|
||||
// to do nothing at all, which made all four Extra cards dead weight in the deck.
|
||||
@@ -635,6 +772,47 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
|
||||
case 'card.discard':
|
||||
return [{ type: 'cardDiscarded', player, cardId: i.cardId, toSlot: i.toSlot }];
|
||||
|
||||
case 'mainline.modify': {
|
||||
const card = s.cards.get(i.cardId)!;
|
||||
const key = card.kind.kind === 'mainlineModifier' ? card.kind.key : '';
|
||||
const node = s.division.nodes[i.node];
|
||||
const became =
|
||||
key === 'realignment' && node?.kind === 'mainline'
|
||||
? REALIGNMENTS.find((r) => r.from === node.card)?.to
|
||||
: undefined;
|
||||
return [
|
||||
{ type: 'mainlineModified', player, cardId: i.cardId, node: i.node, key, ...(became ? { became } : {}) },
|
||||
];
|
||||
}
|
||||
|
||||
case 'maneuver.redFlags': {
|
||||
const tray = s.trays.get(i.trayId)!;
|
||||
const index = tray.position.at === 'mainline' ? tray.position.index : -1;
|
||||
return [{ type: 'redFlagsSet', player, cardId: i.cardId, trayId: i.trayId, node: index }];
|
||||
}
|
||||
|
||||
case 'maneuver.flyingSwitch': {
|
||||
const tray = s.trays.get(i.trayId)!;
|
||||
// §A.3 — cars come off the back, same as a normal drop.
|
||||
const stock = tray.consist.slice(tray.consist.length - i.count);
|
||||
return [{ type: 'flyingSwitch', player, cardId: i.cardId, trayId: i.trayId, to: i.to, stock }];
|
||||
}
|
||||
|
||||
case 'track.lay': {
|
||||
const key = `${i.geometry}:${i.hand}`;
|
||||
return [
|
||||
{
|
||||
type: 'trackLaid',
|
||||
player,
|
||||
geometry: i.geometry,
|
||||
hand: i.hand,
|
||||
at: i.placement,
|
||||
variant: i.variant ?? 0,
|
||||
remaining: (areaOf(s, player).trackSupply.get(key) ?? 1) - 1,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
case 'freightAgent.stockOutbound':
|
||||
return [
|
||||
{ type: 'stockToOutbound', player, at: i.at, stock: { type: i.carType, loaded: true } },
|
||||
@@ -787,6 +965,14 @@ export function reduce(s: GameState, e: GameEvent): void {
|
||||
break;
|
||||
}
|
||||
|
||||
case 'consistSorted': {
|
||||
const tray = s.trays.get(e.trayId)!;
|
||||
tray.consist = e.after.map((c) => ({ ...c }));
|
||||
// "Spends one move in the yard" — the sort costs a Move.
|
||||
s.turn.movesRemaining = Math.max(0, s.turn.movesRemaining - 1);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'carsDropped': {
|
||||
const tray = s.trays.get(e.trayId)!;
|
||||
const area = areaOf(s, tray.position.at === 'grid' ? tray.position.owner : 0);
|
||||
@@ -833,6 +1019,7 @@ export function reduce(s: GameState, e: GameEvent): void {
|
||||
standing: [],
|
||||
facility: null,
|
||||
modifiers: [],
|
||||
enhancements: [],
|
||||
});
|
||||
applyModifier(area, e.placement, card.kind.modifier);
|
||||
} else if (card.kind.kind !== 'office') {
|
||||
@@ -841,6 +1028,52 @@ export function reduce(s: GameState, e: GameEvent): void {
|
||||
break;
|
||||
}
|
||||
|
||||
case 'mainlineModified': {
|
||||
const node = s.division.nodes[e.node];
|
||||
if (node?.kind === 'mainline') {
|
||||
if (e.became) node.card = e.became as MainlineKind;
|
||||
else node.modifiers = [...(node.modifiers ?? []), e.key];
|
||||
}
|
||||
spendCard(s, e.player, e.cardId);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'redFlagsSet': {
|
||||
const node = s.division.nodes[e.node];
|
||||
if (node?.kind === 'mainline') {
|
||||
node.redFlagged = [...(node.redFlagged ?? []), e.trayId];
|
||||
}
|
||||
spendCard(s, e.player, e.cardId);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'flyingSwitch': {
|
||||
const tray = s.trays.get(e.trayId);
|
||||
const area = areaOf(s, e.player);
|
||||
const card = area.grid.get(coordKey(e.to));
|
||||
if (tray && card) {
|
||||
tray.consist = tray.consist.slice(0, tray.consist.length - e.stock.length);
|
||||
const track = card.facility?.industryTrack;
|
||||
if (track) track.cars.push(...e.stock);
|
||||
else card.standing.push(...e.stock);
|
||||
}
|
||||
s.turn.movesRemaining -= 1;
|
||||
spendCard(s, e.player, e.cardId);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'trackLaid': {
|
||||
const area = areaOf(s, e.player);
|
||||
area.trackSupply.set(`${e.geometry}:${e.hand}`, e.remaining);
|
||||
s.turn.laidThisTurn = true;
|
||||
const built = protoTrackCard(e.geometry as never, e.hand as never, e.variant);
|
||||
if (built) {
|
||||
area.grid.set(coordKey(e.at), built);
|
||||
extendLimitsIfNeeded(area, e.at);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'officeUpgraded': {
|
||||
// Gap 8 — a property change, NOT a card swap. Swapping would orphan attached Secondary Track.
|
||||
const area = areaOf(s, e.player);
|
||||
@@ -897,6 +1130,19 @@ export function reduce(s: GameState, e: GameEvent): void {
|
||||
break;
|
||||
}
|
||||
|
||||
case 'enhancementPlaced': {
|
||||
const rule = enhancementRule(e.key);
|
||||
if (rule?.placement === 'mainlineCard') {
|
||||
const node = s.division.nodes[e.at.col];
|
||||
// ABS Signals: trains on this card stop short rather than rear-ending each other.
|
||||
if (node?.kind === 'mainline') node.absSignals = true;
|
||||
} else {
|
||||
const card = areaOf(s, e.player).grid.get(coordKey(e.at));
|
||||
if (card) card.enhancements.push(e.key);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'extraQueued':
|
||||
s.pendingExtras.push(e.trainNumber);
|
||||
break;
|
||||
@@ -1032,7 +1278,7 @@ function protoCard(
|
||||
kind: { kind: string; geometry?: string; facility?: string },
|
||||
variant: number | undefined,
|
||||
): TrackCard | null {
|
||||
const base = { standing: [], facility: null, modifiers: [] };
|
||||
const base = { standing: [], facility: null, modifiers: [], enhancements: [] };
|
||||
|
||||
if (kind.kind === 'track') {
|
||||
const geometry = kind.geometry as TrackGeometry;
|
||||
@@ -1117,6 +1363,122 @@ function adjacentFacilityCoord(area: OfficeArea, coord: GridCoord): GridCoord |
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The three defensive Enhancements protect against cards that only exist in a multiplayer deck, so
|
||||
* their placement and state are implemented and their effect is read at the point of attack:
|
||||
*
|
||||
* - **Facing Point Locks** — prevents `Derail` being played on you (Action card).
|
||||
* - **Water Column** — lets you remove a Watertower from your district (Space-use card).
|
||||
* - **Overpass** — removes the restrictions of a played Railroad Crossing (Action card).
|
||||
*
|
||||
* In solitaire the opponent-directed cards are not in the deck (Q6), so these never fire. They are
|
||||
* queried here rather than being special-cased at each attack site.
|
||||
*/
|
||||
export function hasDistrictEnhancement(area: OfficeArea, key: string): boolean {
|
||||
return [...area.grid.values()].some((c) => c.enhancements.includes(key));
|
||||
}
|
||||
|
||||
/** Facing Point Locks blocks a Derail played at this district. */
|
||||
export function isProtectedFromDerail(area: OfficeArea): boolean {
|
||||
return hasDistrictEnhancement(area, 'facingPointLocks');
|
||||
}
|
||||
|
||||
/** A Water Column lets its owner clear a Watertower off their own grid. */
|
||||
export function watertowersRemovable(area: OfficeArea): GridCoord[] {
|
||||
if (!hasDistrictEnhancement(area, 'waterColumn')) return [];
|
||||
const out: GridCoord[] = [];
|
||||
for (const [key, card] of area.grid) {
|
||||
if (card.geometry.kind !== 'spaceUse' || card.geometry.key !== 'watertower') continue;
|
||||
const [row, col] = key.split(',').map(Number);
|
||||
out.push({ row: row!, col: col! });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enhancements have per-card placement rules (implications.md §7):
|
||||
* - Interlocking, Water Column, Telegraph → a Running Track straight
|
||||
* - Yard Office, Small Yard → a Secondary Track straight
|
||||
* - Telephone / Radio → stacked on the card below them
|
||||
* - Facing Point Locks → needs an Interlocking in the district
|
||||
* - ABS Signals → a Mainline card, not the Office Area
|
||||
*/
|
||||
export function checkEnhancementPlacement(
|
||||
s: GameState,
|
||||
area: OfficeArea,
|
||||
key: string,
|
||||
placement: GridCoord,
|
||||
): RejectionCode | null {
|
||||
const rule = enhancementRule(key);
|
||||
if (!rule) return 'NOT_IMPLEMENTED';
|
||||
|
||||
// ABS Signals goes on a Mainline card; `placement.col` names which one.
|
||||
if (rule.placement === 'mainlineCard') {
|
||||
const node = s.division.nodes[placement.col];
|
||||
return node && node.kind === 'mainline' ? null : 'NOT_CONNECTED';
|
||||
}
|
||||
|
||||
const card = area.grid.get(coordKey(placement));
|
||||
if (!card) return 'NOT_CONNECTED';
|
||||
if (card.enhancements.includes(key)) return 'OPTION_ALREADY_CHOSEN';
|
||||
|
||||
if (rule.requiresOnSameCard && !card.enhancements.includes(rule.requiresOnSameCard)) {
|
||||
return 'NOT_CONNECTED';
|
||||
}
|
||||
if (rule.requiresInDistrict) {
|
||||
const present = [...area.grid.values()].some((c) =>
|
||||
c.enhancements.includes(rule.requiresInDistrict!),
|
||||
);
|
||||
if (!present) return 'NOT_CONNECTED';
|
||||
}
|
||||
|
||||
const onRunning = placement.row === area.runningRow;
|
||||
const isStraight =
|
||||
card.geometry.kind === 'track' && card.geometry.geometry === 'straight';
|
||||
|
||||
switch (rule.placement) {
|
||||
case 'runningTrackStraight':
|
||||
return onRunning && isStraight ? null : 'NOT_CONNECTED';
|
||||
case 'secondaryTrackStraight':
|
||||
return !onRunning && isStraight ? null : 'NOT_CONNECTED';
|
||||
case 'onCard':
|
||||
return null;
|
||||
default:
|
||||
return 'NOT_CONNECTED';
|
||||
}
|
||||
}
|
||||
|
||||
/** Removes a played card from its owner's hand and sends it to the Salvage Yard. */
|
||||
function spendCard(s: GameState, player: PlayerIndex, cardId: CardId): void {
|
||||
s.decks.hands.set(player, (s.decks.hands.get(player) ?? []).filter((c) => c !== cardId));
|
||||
s.decks.salvageYard.push(cardId);
|
||||
}
|
||||
|
||||
/** A track piece from the player's own supply, at the chosen rotation. */
|
||||
function protoTrackCard(
|
||||
geometry: TrackGeometry,
|
||||
hand: Hand,
|
||||
variant: number | undefined,
|
||||
): TrackCard | null {
|
||||
const options = variantsFor(geometry);
|
||||
const v = options[variant ?? 0];
|
||||
if (!v) return null;
|
||||
return {
|
||||
geometry: {
|
||||
kind: 'track',
|
||||
geometry,
|
||||
...(v.axis ? { axis: v.axis } : {}),
|
||||
...(v.turnout ? { turnout: v.turnout } : {}),
|
||||
...(hand !== 'none' ? { hand } : {}),
|
||||
},
|
||||
baseOperationalRail: geometry !== 'turnout',
|
||||
standing: [],
|
||||
facility: null,
|
||||
modifiers: [],
|
||||
enhancements: [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Q4 — would building `kind` here conflict with something already in the district?
|
||||
* The relation is symmetric, so checking either direction is enough.
|
||||
|
||||
+128
-17
@@ -145,13 +145,23 @@ export type IndustryProfile = {
|
||||
copies: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Industry density (Gap 12). The recovered sheet lists 9 industries in a 115-card deck; the
|
||||
* prototype ran 10 in 52. At 9-in-115 a game saw 1.6 Freight Facilities, freight was 10% of gross
|
||||
* revenue, and `carsCoupled` fired 4 times per 100 games — the freight loop, which is the point of
|
||||
* the game, effectively never ran.
|
||||
*
|
||||
* Each industry's `copies` is TRIPLED, giving 27 in 133. That restores roughly the prototype's
|
||||
* ratio while preserving the sheet's proportions exactly: the outbound/inbound balance and the
|
||||
* lockout structure are unchanged, because every kind scales by the same factor.
|
||||
*/
|
||||
export const INDUSTRY_PROFILES: readonly IndustryProfile[] = [
|
||||
{ kind: 'freightHouse', name: 'Freight House', carTypes: ['boxcar'], flow: 'both', baseOut: 1, baseIn: 1, baseLoaders: 1, lockouts: ['grocersWarehouse'], copies: 2 },
|
||||
{ kind: 'mineTipple', name: 'Mine Tipple', carTypes: ['hopper'], flow: 'outbound', baseOut: 1, baseIn: 0, baseLoaders: 1, lockouts: ['powerPlant'], copies: 2 },
|
||||
{ kind: 'refinery', name: 'Refinery', carTypes: ['tank'], flow: 'outbound', baseOut: 1, baseIn: 0, baseLoaders: 1, lockouts: ['powerPlant'], copies: 1 },
|
||||
{ kind: 'powerPlant', name: 'Power Plant', carTypes: ['hopper', 'tank'], flow: 'inbound', baseOut: 0, baseIn: 1, baseLoaders: 1, lockouts: ['mineTipple', 'refinery'], copies: 2 },
|
||||
{ kind: 'packingSheds', name: 'Packing Sheds', carTypes: ['reefer'], flow: 'outbound', baseOut: 1, baseIn: 0, baseLoaders: 1, lockouts: ['grocersWarehouse'], copies: 1 },
|
||||
{ kind: 'grocersWarehouse', name: "Grocer's Warehouse", carTypes: ['boxcar', 'reefer'], flow: 'inbound', baseOut: 0, baseIn: 1, baseLoaders: 1, lockouts: ['packingSheds', 'freightHouse'], copies: 1 },
|
||||
{ kind: 'freightHouse', name: 'Freight House', carTypes: ['boxcar'], flow: 'both', baseOut: 1, baseIn: 1, baseLoaders: 1, lockouts: ['grocersWarehouse'], copies: 6 },
|
||||
{ kind: 'mineTipple', name: 'Mine Tipple', carTypes: ['hopper'], flow: 'outbound', baseOut: 1, baseIn: 0, baseLoaders: 1, lockouts: ['powerPlant'], copies: 6 },
|
||||
{ kind: 'refinery', name: 'Refinery', carTypes: ['tank'], flow: 'outbound', baseOut: 1, baseIn: 0, baseLoaders: 1, lockouts: ['powerPlant'], copies: 3 },
|
||||
{ kind: 'powerPlant', name: 'Power Plant', carTypes: ['hopper', 'tank'], flow: 'inbound', baseOut: 0, baseIn: 1, baseLoaders: 1, lockouts: ['mineTipple', 'refinery'], copies: 6 },
|
||||
{ kind: 'packingSheds', name: 'Packing Sheds', carTypes: ['reefer'], flow: 'outbound', baseOut: 1, baseIn: 0, baseLoaders: 1, lockouts: ['grocersWarehouse'], copies: 3 },
|
||||
{ kind: 'grocersWarehouse', name: "Grocer's Warehouse", carTypes: ['boxcar', 'reefer'], flow: 'inbound', baseOut: 0, baseIn: 1, baseLoaders: 1, lockouts: ['packingSheds', 'freightHouse'], copies: 3 },
|
||||
];
|
||||
|
||||
/** Legacy alias; the engine still reads FREIGHT_PROFILES in places. */
|
||||
@@ -381,6 +391,8 @@ export function crossingStages(
|
||||
kind: MainlineKind,
|
||||
trainSpeed: TrainSpeed,
|
||||
carriesPassengers: boolean,
|
||||
modifiers: readonly string[] = [],
|
||||
direction: Direction = 'east',
|
||||
): number {
|
||||
const profile = MAINLINE_PROFILES.find((m) => m.kind === kind);
|
||||
if (!profile) throw new Error(`unknown mainline card: ${kind}`);
|
||||
@@ -394,14 +406,66 @@ export function crossingStages(
|
||||
mph = carriesPassengers ? profile.speed.passenger : profile.speed.freight;
|
||||
break;
|
||||
case 'grade':
|
||||
// Heavy Grade has no printed number; the modifier cards (Brakeman/Airbrakes/Helpers) are what
|
||||
// improve it. Treated as a 30 until those are implemented.
|
||||
// Heavy Grade has no printed number; the modifier cards are what improve it, so it is a 30
|
||||
// until one is placed.
|
||||
mph = 30;
|
||||
break;
|
||||
}
|
||||
|
||||
const base = mph >= 60 ? 1 : 2;
|
||||
return base + (trainSpeed === 'slow' ? 1 : 0);
|
||||
const stages = base + (trainSpeed === 'slow' ? 1 : 0);
|
||||
return Math.max(1, stages - gradeReduction(profile, modifiers, direction));
|
||||
}
|
||||
|
||||
/**
|
||||
* ASSUMPTION, flagged (§10 Q11): the recovered sheet says Brakeman and Airbrakes give "faster
|
||||
* passage downhill" and Helpers "faster passage uphill", but never says which way a Heavy Grade
|
||||
* climbs. Modelled as **climbing eastward** — an eastbound train is going uphill, a westbound one
|
||||
* downhill. Needs confirming; if grades instead carry their own direction, this becomes a property
|
||||
* of the Mainline node rather than a constant.
|
||||
*
|
||||
* Each applicable card takes a Stage off, never below one: a train cannot cross in no time.
|
||||
* Airbrakes only counts when Brakeman is already there, which the placement rule enforces.
|
||||
*/
|
||||
function gradeReduction(
|
||||
profile: MainlineProfile,
|
||||
modifiers: readonly string[],
|
||||
direction: Direction,
|
||||
): number {
|
||||
if (profile.speed.kind !== 'grade') return 0;
|
||||
const downhill = direction === 'west';
|
||||
let n = 0;
|
||||
if (downhill) {
|
||||
if (modifiers.includes('brakeman')) n++;
|
||||
if (modifiers.includes('airbrakes')) n++;
|
||||
} else if (modifiers.includes('helpers')) {
|
||||
n++;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
export function mainlineProfile(kind: MainlineKind): MainlineProfile {
|
||||
const p = MAINLINE_PROFILES.find((m) => m.kind === kind);
|
||||
if (!p) throw new Error(`unknown mainline card: ${kind}`);
|
||||
return p;
|
||||
}
|
||||
|
||||
/** Which Mainline modifiers may sit on `kind`, and what each additionally requires. */
|
||||
export const MAINLINE_MODIFIER_RULES: readonly {
|
||||
key: string;
|
||||
/** Only placeable on a card whose speed is a grade. */
|
||||
gradeOnly: boolean;
|
||||
/** Another modifier that must already be on the same card. */
|
||||
requiresOnCard?: string;
|
||||
}[] = [
|
||||
{ key: 'brakeman', gradeOnly: true },
|
||||
{ key: 'airbrakes', gradeOnly: true, requiresOnCard: 'brakeman' },
|
||||
{ key: 'helpers', gradeOnly: true },
|
||||
{ key: 'realignment', gradeOnly: false },
|
||||
];
|
||||
|
||||
export function mainlineModifierRule(key: string) {
|
||||
return MAINLINE_MODIFIER_RULES.find((r) => r.key === key) ?? null;
|
||||
}
|
||||
|
||||
/** `Realignment` converts one Mainline card into another. */
|
||||
@@ -431,6 +495,45 @@ export const SPACE_USE_CARDS: readonly SimpleCard[] = [
|
||||
{ key: 'engineerCemetery', name: 'Engineer cemetery', copies: 1, placement: 'adjacent to any straight, curve, turnout, Limit', effect: 'Burns tablespace.' },
|
||||
];
|
||||
|
||||
export type EnhancementKey =
|
||||
| 'interlocking' | 'facingPointLocks' | 'yardOffice' | 'smallYard'
|
||||
| 'waterColumn' | 'overpass' | 'telegraph' | 'telephone' | 'radio' | 'absSignals';
|
||||
|
||||
/** Where an Enhancement may be laid. */
|
||||
export type EnhancementPlacement =
|
||||
| 'runningTrackStraight'
|
||||
| 'secondaryTrackStraight'
|
||||
| 'mainlineCard'
|
||||
| 'onCard';
|
||||
|
||||
export type EnhancementRule = {
|
||||
key: EnhancementKey;
|
||||
placement: EnhancementPlacement;
|
||||
/** Must sit on a card already carrying this enhancement (Telephone on Telegraph, etc.). */
|
||||
requiresOnSameCard?: EnhancementKey;
|
||||
/** Must exist somewhere in the district (Facing Point Locks needs Interlocking). */
|
||||
requiresInDistrict?: EnhancementKey;
|
||||
/** Bonus added to an opposing train's number when resolving a meet, once a Day. */
|
||||
dispatchBonus?: number;
|
||||
};
|
||||
|
||||
export const ENHANCEMENT_RULES: readonly EnhancementRule[] = [
|
||||
{ key: 'interlocking', placement: 'runningTrackStraight' },
|
||||
{ key: 'facingPointLocks', placement: 'onCard', requiresInDistrict: 'interlocking' },
|
||||
{ key: 'yardOffice', placement: 'secondaryTrackStraight' },
|
||||
{ key: 'smallYard', placement: 'secondaryTrackStraight' },
|
||||
{ key: 'waterColumn', placement: 'runningTrackStraight' },
|
||||
{ key: 'overpass', placement: 'onCard' },
|
||||
{ key: 'telegraph', placement: 'runningTrackStraight', dispatchBonus: 4 },
|
||||
{ key: 'telephone', placement: 'onCard', requiresOnSameCard: 'telegraph', dispatchBonus: 8 },
|
||||
{ key: 'radio', placement: 'onCard', requiresOnSameCard: 'telephone', dispatchBonus: 12 },
|
||||
{ key: 'absSignals', placement: 'mainlineCard' },
|
||||
];
|
||||
|
||||
export function enhancementRule(key: string): EnhancementRule | null {
|
||||
return ENHANCEMENT_RULES.find((r) => r.key === key) ?? null;
|
||||
}
|
||||
|
||||
export const ENHANCEMENT_CARDS: readonly SimpleCard[] = [
|
||||
{ key: 'interlocking', name: 'Interlocking', copies: 2, placement: 'any Running Track Straight', effect: 'May stop an inbound train on the Limit Track.' },
|
||||
{ key: 'facingPointLocks', name: 'Facing Point Locks', copies: 2, placement: 'adjacent to Interlocking', effect: 'Must have Interlocking. Prevents Derail being played on you.' },
|
||||
@@ -484,13 +587,21 @@ export const ACTION_CARDS: readonly SimpleCard[] = [
|
||||
|
||||
export type StockSupply = { type: CarType; loaded: number; empty: number };
|
||||
|
||||
/** NOT in the recovered files — still the provisional figure. */
|
||||
/**
|
||||
* NOT in the recovered files — still the provisional figure.
|
||||
*
|
||||
* Scaled up alongside the Gap 12 industry increase. Worst-case demand (every copy of every
|
||||
* industry in play at full capacity) is boxcar 15, hopper 12, tank 9, reefer 6; the supply must
|
||||
* cover that, since a Division Yard that runs dry starves the freight loop the increase exists to
|
||||
* feed. Lockouts and district size mean the worst case cannot actually occur, so this carries
|
||||
* deliberate headroom.
|
||||
*/
|
||||
export const ROLLING_STOCK_SUPPLY: readonly StockSupply[] = [
|
||||
{ type: 'coach', loaded: 8, empty: 8 },
|
||||
{ type: 'boxcar', loaded: 6, empty: 6 },
|
||||
{ type: 'hopper', loaded: 6, empty: 6 },
|
||||
{ type: 'reefer', loaded: 4, empty: 4 },
|
||||
{ type: 'tank', loaded: 4, empty: 4 },
|
||||
{ type: 'boxcar', loaded: 10, empty: 10 },
|
||||
{ type: 'hopper', loaded: 8, empty: 8 },
|
||||
{ type: 'reefer', loaded: 5, empty: 5 },
|
||||
{ type: 'tank', loaded: 6, empty: 6 },
|
||||
{ type: 'caboose', loaded: 6, empty: 0 },
|
||||
];
|
||||
|
||||
@@ -537,7 +648,7 @@ export function lengthProfile(length: GameLength): LengthProfile {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Deck composition — 115 cards
|
||||
// Deck composition — 133 cards
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
@@ -565,9 +676,9 @@ export function deckComposition(): { category: string; count: number }[] {
|
||||
];
|
||||
}
|
||||
|
||||
export const DECK_SIZE = deckComposition().reduce((n, c) => n + c.count, 0); // 115
|
||||
export const DECK_SIZE = deckComposition().reduce((n, c) => n + c.count, 0); // 133
|
||||
|
||||
/** The solitaire deck drops the 22 opponent-directed cards, leaving 93. */
|
||||
/** The solitaire deck drops the 22 opponent-directed cards, leaving 111. */
|
||||
export const SOLITAIRE_DECK_SIZE = deckComposition()
|
||||
.filter((c) => !isOpponentOnly(c.category))
|
||||
.reduce((n, c) => n + c.count, 0);
|
||||
|
||||
@@ -24,9 +24,14 @@ export type GameEvent =
|
||||
| { type: 'trayMoved'; trayId: TrayId; from: GridCoord; to: GridCoord; movesRemaining: number }
|
||||
| { type: 'carsCoupled'; trayId: TrayId; at: GridCoord; stock: RollingStock[] }
|
||||
| { type: 'carsDropped'; trayId: TrayId; at: GridCoord; stock: RollingStock[] }
|
||||
| { type: 'consistSorted'; trayId: TrayId; at: GridCoord; before: RollingStock[]; after: RollingStock[] }
|
||||
| { type: 'cardDrawn'; player: PlayerIndex; source: 'homeOffice' | 'department'; slot?: number; cardId: CardId }
|
||||
/** `variant` is the chosen orientation (Gap 11); it must be replayable, so it rides the event. */
|
||||
| { type: 'cardPlayed'; player: PlayerIndex; cardId: CardId; placement?: GridCoord; variant?: number }
|
||||
| { type: 'trackLaid'; player: PlayerIndex; geometry: string; hand: string; at: GridCoord; variant: number; remaining: number }
|
||||
| { type: 'mainlineModified'; player: PlayerIndex; cardId: CardId; node: number; key: string; became?: string }
|
||||
| { type: 'redFlagsSet'; player: PlayerIndex; cardId: CardId; trayId: TrayId; node: number }
|
||||
| { type: 'flyingSwitch'; player: PlayerIndex; cardId: CardId; trayId: TrayId; to: GridCoord; stock: RollingStock[] }
|
||||
| { type: 'officeUpgraded'; player: PlayerIndex; from: OfficeTier; to: OfficeTier }
|
||||
| { type: 'cardDiscarded'; player: PlayerIndex; cardId: CardId; toSlot: number }
|
||||
| { type: 'deckReshuffled' }
|
||||
@@ -46,15 +51,18 @@ export type GameEvent =
|
||||
* which made the replay say "New Train" and nothing else. A train being made up, departing,
|
||||
* arriving or finishing its run are four distinct facts and deserve four event types.
|
||||
*/
|
||||
| { type: 'enhancementPlaced'; player: PlayerIndex; key: string; at: GridCoord }
|
||||
| { type: 'extraQueued'; player: PlayerIndex; trainNumber: number }
|
||||
| { type: 'secondSectionOrdered'; player: PlayerIndex; trainNumber: number }
|
||||
| { type: 'trainMadeUp'; trainNumber: number; isExtra: boolean; at: string; direction: string }
|
||||
| { type: 'trainHeld'; trainNumber: number; reason: string }
|
||||
| { type: 'trainHighballed'; trainNumber: number; from: string; to: string }
|
||||
| { type: 'trainArrived'; trainNumber: number; consist: RollingStock[]; office: string }
|
||||
| { type: 'trainDiverted'; trainNumber: number; to: string; reason: string }
|
||||
| { type: 'trainCompleted'; trainNumber: number; consist: RollingStock[] }
|
||||
| { type: 'carPlacedOnTrain'; player: PlayerIndex; trayId: TrayId; stock: RollingStock }
|
||||
| { type: 'carPassed'; player: PlayerIndex; trayId: TrayId }
|
||||
| { type: 'dispatchBonusUsed'; key: string; bonus: number; trainNumber: number; againstTrain: number }
|
||||
| { type: 'clearanceRequested'; trainId: TrayId; occupiedBy: TrayId }
|
||||
| { type: 'clearanceGiven'; trainId: TrayId; allow: boolean }
|
||||
// -- load / unload
|
||||
|
||||
+34
-1
@@ -4,7 +4,7 @@
|
||||
* An intent is a PROPOSAL. It may be rejected. Contrast with an event (events.ts), which is a fact.
|
||||
*/
|
||||
|
||||
import type { CarType, Direction, OfficeTier } from './content.ts';
|
||||
import type { CarType, Direction, Hand, OfficeTier, TrackGeometry } from './content.ts';
|
||||
import type { CardId, GridCoord, PlayerIndex, TrayId } from './state.ts';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -18,6 +18,12 @@ export type Intent =
|
||||
// -- switch (§6.1, Appendix A)
|
||||
| { type: 'switch.move'; trayId: TrayId; to: GridCoord; reverse: boolean }
|
||||
| { type: 'switch.dropCars'; trayId: TrayId; count: number }
|
||||
/**
|
||||
* Small Yard enhancement — "a train that spends one move in the yard may sort itself in any
|
||||
* order, including cars in front of the engine". This is the designed answer to §A.3's
|
||||
* come-off-in-seated-order constraint, which is what makes facing-point work possible.
|
||||
*/
|
||||
| { type: 'switch.sortConsist'; trayId: TrayId; order: number[] }
|
||||
| { type: 'switch.end' }
|
||||
// -- draw (§6.2)
|
||||
| { type: 'draw.fromHomeOffice' }
|
||||
@@ -25,6 +31,14 @@ export type Intent =
|
||||
/** `variant` indexes `variantsFor(geometry)` — Gap 11: orientation is chosen on placement. */
|
||||
| { type: 'card.play'; cardId: CardId; placement?: GridCoord; variant?: number }
|
||||
| { type: 'card.discard'; cardId: CardId; toSlot: number }
|
||||
/**
|
||||
* Lay a piece from your personal track supply (§12.2 / content.ts TRACK_SUPPLY).
|
||||
*
|
||||
* ASSUMPTION, flagged: the design moves track out of the deck into a per-player supply but does
|
||||
* not say when you may lay it. Treated as a play available during the "draw a card" option,
|
||||
* matching how track behaved when it WAS a card. See implications.md §10 Q10.
|
||||
*/
|
||||
| { type: 'track.lay'; geometry: TrackGeometry; hand: Hand; placement: GridCoord; variant?: number }
|
||||
| { type: 'draw.end' }
|
||||
// -- freight agent (§6.3)
|
||||
| { type: 'freightAgent.stockOutbound'; at: GridCoord; carType: CarType }
|
||||
@@ -37,6 +51,21 @@ export type Intent =
|
||||
| { type: 'newTrain.secondSection'; trainNumber: number }
|
||||
// -- Mainline Phase (§8.1) — the Superintendent's clearance ruling
|
||||
| { type: 'mainline.clearance'; allow: boolean }
|
||||
/**
|
||||
* Lay a Mainline modifier (Brakeman / Airbrakes / Helpers / Realignment) on a Mainline card.
|
||||
* `node` indexes `division.nodes`.
|
||||
*/
|
||||
| { type: 'mainline.modify'; cardId: CardId; node: number }
|
||||
/**
|
||||
* Red Flags — protect a stopped train. The flagged train cannot be hit; an approaching train is
|
||||
* held instead of colliding.
|
||||
*/
|
||||
| { type: 'maneuver.redFlags'; cardId: CardId; trayId: TrayId }
|
||||
/**
|
||||
* Flying Switch — cut cars off behind the engine and roll them into an adjacent industry, without
|
||||
* the engine entering it.
|
||||
*/
|
||||
| { type: 'maneuver.flyingSwitch'; cardId: CardId; trayId: TrayId; count: number; to: GridCoord }
|
||||
| { type: 'redFlag.play' }
|
||||
// -- Load/Unload Phase (§9)
|
||||
| { type: 'porter.board'; at: GridCoord }
|
||||
@@ -83,6 +112,10 @@ export type RejectionCode =
|
||||
| 'NOT_UPGRADEABLE'
|
||||
| 'FACILITY_LOCKED'
|
||||
| 'NOT_IMPLEMENTED'
|
||||
| 'WRONG_INTENT'
|
||||
| 'NOT_A_GRADE'
|
||||
| 'TRAIN_ON_CARD'
|
||||
| 'CONSIST_ORDER'
|
||||
| 'NO_TRAIN_AT_OFFICE';
|
||||
|
||||
export type Rejection = { code: RejectionCode; message: string };
|
||||
|
||||
+68
-2
@@ -12,7 +12,7 @@
|
||||
* If you find yourself writing a rule here, it belongs in apply.ts.
|
||||
*/
|
||||
|
||||
import type { CarType } from './content.ts';
|
||||
import type { CarType, Hand, TrackGeometry } from './content.ts';
|
||||
import { check, areaOf, destinationsFor } from './apply.ts';
|
||||
import type { Intent } from './intents.ts';
|
||||
import type { GameState, GridCoord, PlayerIndex } from './state.ts';
|
||||
@@ -56,6 +56,14 @@ function candidates(s: GameState, player: PlayerIndex): Intent[] {
|
||||
break;
|
||||
}
|
||||
|
||||
// Red Flags — "any time", so they are candidates in every phase, on any train standing out on
|
||||
// the Mainline (the player's own or another's: protecting a train is not an attack).
|
||||
for (const cardId of s.decks.hands.get(player) ?? []) {
|
||||
const k = s.cards.get(cardId)?.kind;
|
||||
if (k?.kind !== 'maneuver' || k.key !== 'redFlags') continue;
|
||||
for (const [trayId] of s.trays) out.push({ type: 'maneuver.redFlags', cardId, trayId });
|
||||
}
|
||||
|
||||
out.push({ type: 'redFlag.play' });
|
||||
return out;
|
||||
}
|
||||
@@ -82,6 +90,38 @@ function localOpsCandidates(s: GameState, player: PlayerIndex): Intent[] {
|
||||
for (let n = 1; n <= tray.consist.length; n++) {
|
||||
out.push({ type: 'switch.dropCars', trayId, count: n });
|
||||
}
|
||||
// Small Yard: enumerating every permutation would explode, so offer the useful ones —
|
||||
// bringing each car to the droppable end, plus a full reversal. `check` validates any order,
|
||||
// so a UI may submit an arbitrary permutation.
|
||||
const n = tray.consist.length;
|
||||
if (n > 1) {
|
||||
for (let k = 0; k < n; k++) {
|
||||
const order = [...Array(n).keys()].filter((x) => x !== k);
|
||||
order.push(k);
|
||||
out.push({ type: 'switch.sortConsist', trayId, order });
|
||||
}
|
||||
out.push({ type: 'switch.sortConsist', trayId, order: [...Array(n).keys()].reverse() });
|
||||
}
|
||||
}
|
||||
// Flying Switch — roll a cut into an ADJACENT industry without the engine entering it.
|
||||
for (const cardId of s.decks.hands.get(player) ?? []) {
|
||||
const k = s.cards.get(cardId)?.kind;
|
||||
if (k?.kind !== 'maneuver' || k.key !== 'flyingSwitch') continue;
|
||||
for (const [trayId, tray] of s.trays) {
|
||||
if (tray.position.at !== 'grid' || tray.position.owner !== player) continue;
|
||||
const { row, col } = tray.position.coord;
|
||||
const around: GridCoord[] = [
|
||||
{ row: row + 1, col },
|
||||
{ row: row - 1, col },
|
||||
{ row, col: col + 1 },
|
||||
{ row, col: col - 1 },
|
||||
];
|
||||
for (const to of around) {
|
||||
for (let count = 1; count <= tray.consist.length; count++) {
|
||||
out.push({ type: 'maneuver.flyingSwitch', cardId, trayId, count, to });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out.push({ type: 'switch.end' });
|
||||
|
||||
@@ -90,17 +130,43 @@ function localOpsCandidates(s: GameState, player: PlayerIndex): Intent[] {
|
||||
for (let slot = 0; slot < 3; slot++) out.push({ type: 'draw.fromDepartment', slot });
|
||||
|
||||
const placements = placementCandidates(s, player);
|
||||
// Enhancements ATTACH to a card already in the grid, so their candidates are the occupied cells,
|
||||
// not the empty ones every other placeable card wants. Offering them `placements` meant an
|
||||
// Enhancement was never once legal on a real card — 18 of 93 solitaire cards, permanently dead.
|
||||
const attachments = [...area.grid.keys()].map((k) => {
|
||||
const [row, col] = k.split(',').map(Number);
|
||||
return { row: row!, col: col! };
|
||||
});
|
||||
for (const cardId of s.decks.hands.get(player) ?? []) {
|
||||
out.push({ type: 'card.play', cardId });
|
||||
// Gap 11 — orientation is chosen on placement, so every rotation is a distinct candidate.
|
||||
// Six is the widest set (turnouts); `check` discards the ones whose ports do not meet.
|
||||
for (const placement of placements) {
|
||||
const targets = s.cards.get(cardId)?.kind.kind === 'enhancement' ? attachments : placements;
|
||||
for (const placement of targets) {
|
||||
for (let variant = 0; variant < 6; variant++) {
|
||||
out.push({ type: 'card.play', cardId, placement, variant });
|
||||
}
|
||||
}
|
||||
for (let slot = 0; slot < 3; slot++) out.push({ type: 'card.discard', cardId, toSlot: slot });
|
||||
}
|
||||
// Lay track from the personal supply — the only way a district grows now that track is not in
|
||||
// the deck.
|
||||
for (const [key, count] of s.turn.laidThisTurn ? [] : area.trackSupply) {
|
||||
if (count < 1) continue;
|
||||
const [geometry, hand] = key.split(':') as [TrackGeometry, Hand];
|
||||
for (const placement of placements) {
|
||||
for (let variant = 0; variant < 2; variant++) {
|
||||
out.push({ type: 'track.lay', geometry, hand, placement, variant });
|
||||
}
|
||||
}
|
||||
}
|
||||
// Mainline modifiers go on a Mainline card, not a grid cell, so `node` indexes division.nodes.
|
||||
for (const cardId of s.decks.hands.get(player) ?? []) {
|
||||
if (s.cards.get(cardId)?.kind.kind !== 'mainlineModifier') continue;
|
||||
for (let node = 0; node < s.division.nodes.length; node++) {
|
||||
out.push({ type: 'mainline.modify', cardId, node });
|
||||
}
|
||||
}
|
||||
out.push({ type: 'draw.end' });
|
||||
|
||||
// -- freight agent (§6.3)
|
||||
|
||||
+16
-1
@@ -17,6 +17,7 @@ import {
|
||||
MODIFIER_PROFILES,
|
||||
OFFICE_PROFILES,
|
||||
MAINLINE_PROFILES,
|
||||
TRACK_SUPPLY,
|
||||
ROLLING_STOCK_SUPPLY,
|
||||
STAGES_PER_DAY,
|
||||
TIMETABLED_TRAINS,
|
||||
@@ -122,6 +123,7 @@ function buildOfficeArea(owner: PlayerIndex): OfficeArea {
|
||||
standing: [],
|
||||
facility: buildPassengerFacility('whistlePost'),
|
||||
modifiers: [],
|
||||
enhancements: [],
|
||||
};
|
||||
|
||||
const limitsCard = (): TrackCard => ({
|
||||
@@ -130,6 +132,7 @@ function buildOfficeArea(owner: PlayerIndex): OfficeArea {
|
||||
standing: [],
|
||||
facility: null,
|
||||
modifiers: [],
|
||||
enhancements: [],
|
||||
});
|
||||
|
||||
const grid = new Map<string, TrackCard>();
|
||||
@@ -137,7 +140,19 @@ function buildOfficeArea(owner: PlayerIndex): OfficeArea {
|
||||
grid.set(coordKey(limitsWest), limitsCard());
|
||||
grid.set(coordKey(limitsEast), limitsCard());
|
||||
|
||||
return { owner, tier: 'whistlePost', grid, officeCoord, runningRow: row, limitsWest, limitsEast, adOccupancy: [] };
|
||||
return {
|
||||
owner,
|
||||
tier: 'whistlePost',
|
||||
grid,
|
||||
officeCoord,
|
||||
runningRow: row,
|
||||
limitsWest,
|
||||
limitsEast,
|
||||
adOccupancy: [],
|
||||
heldAtLimits: [],
|
||||
dispatchUsedToday: [],
|
||||
trackSupply: new Map(TRACK_SUPPLY.map((t) => [`${t.geometry}:${t.hand}`, t.perPlayer])),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+33
-2
@@ -73,7 +73,9 @@ export type CardGeometry =
|
||||
| { kind: 'limits' }
|
||||
| { kind: 'facility'; facility: FreightKind; axis?: TrackAxis }
|
||||
/** Not track — a Modifier sits beside a Facility and raises its capacity (§9). */
|
||||
| { kind: 'modifier'; modifier: ModifierKind };
|
||||
| { kind: 'modifier'; modifier: ModifierKind }
|
||||
/** Not track — a Space-use card played at a district to consume a cell (Q6). */
|
||||
| { kind: 'spaceUse'; key: string };
|
||||
|
||||
export type TrackCard = {
|
||||
geometry: CardGeometry;
|
||||
@@ -86,6 +88,8 @@ export type TrackCard = {
|
||||
standing: RollingStock[];
|
||||
facility: Facility | null;
|
||||
modifiers: ModifierKind[];
|
||||
/** Enhancement cards laid on this card (§7 of implications.md). */
|
||||
enhancements: string[];
|
||||
};
|
||||
|
||||
export type OfficeArea = {
|
||||
@@ -99,6 +103,18 @@ export type OfficeArea = {
|
||||
limitsEast: GridCoord;
|
||||
/** Trays holding at the Office. Length must never exceed the tier's A/D track count. */
|
||||
adOccupancy: TrayId[];
|
||||
/**
|
||||
* Trains held at the Limits by an Interlocking rather than admitted to the Office. They are
|
||||
* inside the player's Limits but not occupying an A/D track.
|
||||
*/
|
||||
heldAtLimits: TrayId[];
|
||||
/** Dispatch bonuses spent this Day, by enhancement key — each is once a Day. */
|
||||
dispatchUsedToday: string[];
|
||||
/**
|
||||
* The player's personal track supply (26 pieces), keyed `geometry:hand`. Track is NOT in the
|
||||
* Home Office deck — it is laid from here.
|
||||
*/
|
||||
trackSupply: Map<string, number>;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -198,7 +214,16 @@ export type Transit = { tray: TrayId; stagesRemaining: number; direction: Direct
|
||||
|
||||
export type DivisionNode =
|
||||
| { kind: 'divisionPoint'; side: Direction; holding: TrayId[] }
|
||||
| { kind: 'mainline'; card: MainlineKind; transits: Transit[] }
|
||||
| {
|
||||
kind: 'mainline';
|
||||
card: MainlineKind;
|
||||
transits: Transit[];
|
||||
absSignals?: boolean;
|
||||
/** Brakeman / Airbrakes / Helpers / Realignment laid on this card. */
|
||||
modifiers?: string[];
|
||||
/** Red Flags protecting a stopped train here, by tray. */
|
||||
redFlagged?: TrayId[];
|
||||
}
|
||||
| { kind: 'office'; owner: PlayerIndex };
|
||||
|
||||
/** Ordered west to east. For N players: N Office nodes and N+1 Mainline cards. */
|
||||
@@ -328,6 +353,11 @@ export type TurnState = {
|
||||
option: 'switch' | 'draw' | 'freightAgent' | null;
|
||||
movesRemaining: number;
|
||||
drawnThisTurn: boolean;
|
||||
/**
|
||||
* Track was a CARD before it became a per-player supply, so it was naturally limited to one play
|
||||
* a turn. The supply has 26 pieces and nothing else bounds it, so keep that limit explicitly.
|
||||
*/
|
||||
laidThisTurn: boolean;
|
||||
freightAgentUsed: boolean;
|
||||
/** Set when the actor finishes; the phase driver then moves to the next player. */
|
||||
done: boolean;
|
||||
@@ -338,6 +368,7 @@ export function freshTurn(moves: number): TurnState {
|
||||
option: null,
|
||||
movesRemaining: moves,
|
||||
drawnThisTurn: false,
|
||||
laidThisTurn: false,
|
||||
freightAgentUsed: false,
|
||||
done: false,
|
||||
};
|
||||
|
||||
+2
-1
@@ -79,8 +79,9 @@ type PortPair = readonly [Port, Port];
|
||||
*/
|
||||
function connectionsFor(card: TrackCard): readonly PortPair[] {
|
||||
switch (card.geometry.kind) {
|
||||
// A Modifier is not track: nothing connects to it and no train may enter (§9).
|
||||
// Neither a Modifier nor a Space-use card is track: nothing connects, no train may enter.
|
||||
case 'modifier':
|
||||
case 'spaceUse':
|
||||
return [];
|
||||
case 'limits':
|
||||
return [['e', 'w']];
|
||||
|
||||
Reference in New Issue
Block a user