Implement Enhancements, Mainline modifiers and Maneuvers; fix unplayable track

This commit is contained in:
Jesse
2026-07-31 11:02:54 -04:00
parent 401b879140
commit b085d5a0bb
20 changed files with 1853 additions and 50 deletions
+366 -4
View File
@@ -19,15 +19,20 @@ import {
HAND_LIMIT,
LABORER_ACTIONS_PER_LOAD,
MAX_CONSIST,
REALIGNMENTS,
enhancementRule,
industryProfile,
mainlineModifierRule,
mainlineProfile,
modifierProfile,
nextOfficeTier,
officeProfile,
} from './content.ts';
import type { CarType, FreightKind, ModifierKind, TrackGeometry } from './content.ts';
import type { CarType, FreightKind, Hand, MainlineKind, ModifierKind, TrackGeometry } from './content.ts';
import type { GameEvent } from './events.ts';
import type { Intent, RejectionCode } from './intents.ts';
import type {
CardId,
Facility,
GameState,
GridCoord,
@@ -272,6 +277,25 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
return canDropCarsAt(areaOf(s, player), here, i.count) ? null : 'CANNOT_DROP_HERE';
}
case 'switch.sortConsist': {
if (!inPhase(s, 'localOps')) return 'WRONG_PHASE';
if (s.turn.option !== 'switch') return 'OPTION_NOT_CHOSEN';
if (s.turn.movesRemaining < 1) return 'NO_MOVES_REMAINING';
const tray = s.trays.get(i.trayId);
if (!tray) return 'NO_SUCH_TRAY';
const here = trayCoord(s, i.trayId);
if (!here) return 'ILLEGAL_MOVE';
// Only on a card carrying a Small Yard, and it costs the Move it "spends in the yard".
const card = areaOf(s, player).grid.get(coordKey(here));
if (!card?.enhancements.includes('smallYard')) return 'NOT_CONNECTED';
// The order must be a permutation of the current consist.
if (i.order.length !== tray.consist.length) return 'CONSIST_ORDER';
const seen = new Set(i.order);
if (seen.size !== i.order.length) return 'CONSIST_ORDER';
if (i.order.some((n) => n < 0 || n >= tray.consist.length)) return 'CONSIST_ORDER';
return null;
}
case 'switch.end':
if (!inPhase(s, 'localOps')) return 'WRONG_PHASE';
return s.turn.option === 'switch' ? null : 'OPTION_NOT_CHOSEN';
@@ -307,6 +331,78 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
return null;
}
case 'track.lay': {
if (!inPhase(s, 'localOps')) return 'WRONG_PHASE';
if (s.turn.option !== 'draw') return 'OPTION_NOT_CHOSEN';
if (s.turn.laidThisTurn) return 'OPTION_ALREADY_CHOSEN';
const key = `${i.geometry}:${i.hand}`;
if ((areaOf(s, player).trackSupply.get(key) ?? 0) < 1) return 'NO_SUCH_CARD';
const proto = protoTrackCard(i.geometry, i.hand, i.variant);
if (!proto) return 'NO_PLACEMENT';
return canPlaceAt(areaOf(s, player), i.placement, proto) ? null : 'NOT_CONNECTED';
}
case 'mainline.modify': {
if (!inPhase(s, 'localOps')) return 'WRONG_PHASE';
if (s.turn.option !== 'draw') return 'OPTION_NOT_CHOSEN';
const card = s.cards.get(i.cardId);
if (!card || !(s.decks.hands.get(player) ?? []).includes(i.cardId)) return 'NO_SUCH_CARD';
if (card.kind.kind !== 'mainlineModifier') return 'WRONG_INTENT';
const rule = mainlineModifierRule(card.kind.key);
if (!rule) return 'NOT_IMPLEMENTED';
const node = s.division.nodes[i.node];
if (!node || node.kind !== 'mainline') return 'NO_PLACEMENT';
const on = node.modifiers ?? [];
if (on.includes(rule.key)) return 'OPTION_ALREADY_CHOSEN';
if (rule.gradeOnly && mainlineProfile(node.card).speed.kind !== 'grade') return 'NOT_A_GRADE';
if (rule.requiresOnCard && !on.includes(rule.requiresOnCard)) return 'NOT_CONNECTED';
// "Not while a train is on it" — realigning under a moving train is exactly the situation the
// restriction exists to prevent.
if (node.transits.length > 0) return 'TRAIN_ON_CARD';
if (rule.key === 'realignment' && !REALIGNMENTS.some((r) => r.from === node.card)) {
return 'NO_PLACEMENT';
}
return null;
}
case 'maneuver.redFlags': {
const card = s.cards.get(i.cardId);
if (!card || !(s.decks.hands.get(player) ?? []).includes(i.cardId)) return 'NO_SUCH_CARD';
if (card.kind.kind !== 'maneuver' || card.kind.key !== 'redFlags') return 'WRONG_INTENT';
const tray = s.trays.get(i.trayId);
if (!tray) return 'NO_SUCH_TRAY';
// "A STOPPED train is prevented from being hit" — it protects a train that is standing on a
// Mainline card, which is the only place a rear-ender can happen.
if (tray.position.at !== 'mainline') return 'NO_PLACEMENT';
const node = s.division.nodes[tray.position.index];
if (!node || node.kind !== 'mainline') return 'NO_PLACEMENT';
if ((node.redFlagged ?? []).includes(i.trayId)) return 'OPTION_ALREADY_CHOSEN';
return null;
}
case 'maneuver.flyingSwitch': {
if (!inPhase(s, 'localOps')) return 'WRONG_PHASE';
if (s.turn.option !== 'switch') return 'OPTION_NOT_CHOSEN';
if (s.turn.movesRemaining < 1) return 'NO_MOVES_REMAINING';
const card = s.cards.get(i.cardId);
if (!card || !(s.decks.hands.get(player) ?? []).includes(i.cardId)) return 'NO_SUCH_CARD';
if (card.kind.kind !== 'maneuver' || card.kind.key !== 'flyingSwitch') return 'WRONG_INTENT';
const tray = s.trays.get(i.trayId);
if (!tray) return 'NO_SUCH_TRAY';
const here = trayCoord(s, i.trayId);
if (!here) return 'CANNOT_DROP_HERE';
if (i.count < 1 || i.count > tray.consist.length) return 'CONSIST_EMPTY';
// The cut rolls into an ADJACENT industry — the engine never enters, which is the whole point
// of the move and why it beats a normal spot.
const adjacent =
Math.abs(i.to.row - here.row) + Math.abs(i.to.col - here.col) === 1;
if (!adjacent) return 'NOT_CONNECTED';
const fsArea = areaOf(s, player);
const target = fsArea.grid.get(coordKey(i.to));
if (!target?.facility || target.facility.kind !== 'freight') return 'CANNOT_DROP_HERE';
return canDropCarsAt(fsArea, i.to, i.count) ? null : 'CANNOT_DROP_HERE';
}
case 'draw.end': {
if (!inPhase(s, 'localOps')) return 'WRONG_PHASE';
if (s.turn.option !== 'draw') return 'OPTION_NOT_CHOSEN';
@@ -478,10 +574,29 @@ function checkPlay(
// spots) or it does nothing at all, so placing it anywhere else is not a legal play.
return adjacentFacilityCoord(area, placement) ? null : 'NOT_CONNECTED';
}
case 'spaceUse':
case 'enhancement':
case 'enhancement': {
if (!placement) return 'NO_PLACEMENT';
return checkEnhancementPlacement(s, area, card.kind.key, placement);
}
case 'mainlineModifier':
// Facing Point Locks appears in BOTH the Enhancement and Mainline-modifier lists on the sheet,
// with the same placement and the same effect. It is one card printed twice, so the mainline
// copy uses the enhancement's grid placement rather than going onto a Mainline card.
if (card.kind.key === 'facingPointLocksMainline') {
if (!placement) return 'NO_PLACEMENT';
return checkEnhancementPlacement(s, area, 'facingPointLocks', placement);
}
// The rest are laid on a Mainline card, which is not a grid coordinate — see
// `mainline.modify`.
return 'WRONG_INTENT';
case 'maneuver':
// Red Flags and Flying Switch have their own intents; Poling's effect is recorded as "TBD in
// the source", so there is nothing to implement.
return 'WRONG_INTENT';
case 'spaceUse':
case 'action':
// Recovered from the design but not yet implemented — see docs/rules/implications.md §7 and
// §10. Rejecting is honest: silently accepting would make the card look playable while doing
@@ -552,6 +667,20 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
return [{ type: 'carsDropped', trayId: i.trayId, at: here, stock }];
}
case 'switch.sortConsist': {
const tray = s.trays.get(i.trayId)!;
const here = trayCoord(s, i.trayId)!;
return [
{
type: 'consistSorted',
trayId: i.trayId,
at: here,
before: tray.consist.map((c) => ({ ...c })),
after: i.order.map((n) => ({ ...tray.consist[n]! })),
},
];
}
case 'switch.end':
case 'draw.end':
return [{ type: 'phaseEnded', player, phase: 'localOps' }];
@@ -607,6 +736,14 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
to: card.kind.tier,
});
}
if (card.kind.kind === 'enhancement' && i.placement) {
events.push({
type: 'enhancementPlaced',
player,
key: card.kind.key,
at: i.placement,
});
}
if (card.kind.kind === 'extraTrain') {
// §7 — an Extra runs once, immediately, as soon as a Crew Tray frees up. Playing one used
// to do nothing at all, which made all four Extra cards dead weight in the deck.
@@ -635,6 +772,47 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
case 'card.discard':
return [{ type: 'cardDiscarded', player, cardId: i.cardId, toSlot: i.toSlot }];
case 'mainline.modify': {
const card = s.cards.get(i.cardId)!;
const key = card.kind.kind === 'mainlineModifier' ? card.kind.key : '';
const node = s.division.nodes[i.node];
const became =
key === 'realignment' && node?.kind === 'mainline'
? REALIGNMENTS.find((r) => r.from === node.card)?.to
: undefined;
return [
{ type: 'mainlineModified', player, cardId: i.cardId, node: i.node, key, ...(became ? { became } : {}) },
];
}
case 'maneuver.redFlags': {
const tray = s.trays.get(i.trayId)!;
const index = tray.position.at === 'mainline' ? tray.position.index : -1;
return [{ type: 'redFlagsSet', player, cardId: i.cardId, trayId: i.trayId, node: index }];
}
case 'maneuver.flyingSwitch': {
const tray = s.trays.get(i.trayId)!;
// §A.3 — cars come off the back, same as a normal drop.
const stock = tray.consist.slice(tray.consist.length - i.count);
return [{ type: 'flyingSwitch', player, cardId: i.cardId, trayId: i.trayId, to: i.to, stock }];
}
case 'track.lay': {
const key = `${i.geometry}:${i.hand}`;
return [
{
type: 'trackLaid',
player,
geometry: i.geometry,
hand: i.hand,
at: i.placement,
variant: i.variant ?? 0,
remaining: (areaOf(s, player).trackSupply.get(key) ?? 1) - 1,
},
];
}
case 'freightAgent.stockOutbound':
return [
{ type: 'stockToOutbound', player, at: i.at, stock: { type: i.carType, loaded: true } },
@@ -787,6 +965,14 @@ export function reduce(s: GameState, e: GameEvent): void {
break;
}
case 'consistSorted': {
const tray = s.trays.get(e.trayId)!;
tray.consist = e.after.map((c) => ({ ...c }));
// "Spends one move in the yard" — the sort costs a Move.
s.turn.movesRemaining = Math.max(0, s.turn.movesRemaining - 1);
break;
}
case 'carsDropped': {
const tray = s.trays.get(e.trayId)!;
const area = areaOf(s, tray.position.at === 'grid' ? tray.position.owner : 0);
@@ -833,6 +1019,7 @@ export function reduce(s: GameState, e: GameEvent): void {
standing: [],
facility: null,
modifiers: [],
enhancements: [],
});
applyModifier(area, e.placement, card.kind.modifier);
} else if (card.kind.kind !== 'office') {
@@ -841,6 +1028,52 @@ export function reduce(s: GameState, e: GameEvent): void {
break;
}
case 'mainlineModified': {
const node = s.division.nodes[e.node];
if (node?.kind === 'mainline') {
if (e.became) node.card = e.became as MainlineKind;
else node.modifiers = [...(node.modifiers ?? []), e.key];
}
spendCard(s, e.player, e.cardId);
break;
}
case 'redFlagsSet': {
const node = s.division.nodes[e.node];
if (node?.kind === 'mainline') {
node.redFlagged = [...(node.redFlagged ?? []), e.trayId];
}
spendCard(s, e.player, e.cardId);
break;
}
case 'flyingSwitch': {
const tray = s.trays.get(e.trayId);
const area = areaOf(s, e.player);
const card = area.grid.get(coordKey(e.to));
if (tray && card) {
tray.consist = tray.consist.slice(0, tray.consist.length - e.stock.length);
const track = card.facility?.industryTrack;
if (track) track.cars.push(...e.stock);
else card.standing.push(...e.stock);
}
s.turn.movesRemaining -= 1;
spendCard(s, e.player, e.cardId);
break;
}
case 'trackLaid': {
const area = areaOf(s, e.player);
area.trackSupply.set(`${e.geometry}:${e.hand}`, e.remaining);
s.turn.laidThisTurn = true;
const built = protoTrackCard(e.geometry as never, e.hand as never, e.variant);
if (built) {
area.grid.set(coordKey(e.at), built);
extendLimitsIfNeeded(area, e.at);
}
break;
}
case 'officeUpgraded': {
// Gap 8 — a property change, NOT a card swap. Swapping would orphan attached Secondary Track.
const area = areaOf(s, e.player);
@@ -897,6 +1130,19 @@ export function reduce(s: GameState, e: GameEvent): void {
break;
}
case 'enhancementPlaced': {
const rule = enhancementRule(e.key);
if (rule?.placement === 'mainlineCard') {
const node = s.division.nodes[e.at.col];
// ABS Signals: trains on this card stop short rather than rear-ending each other.
if (node?.kind === 'mainline') node.absSignals = true;
} else {
const card = areaOf(s, e.player).grid.get(coordKey(e.at));
if (card) card.enhancements.push(e.key);
}
break;
}
case 'extraQueued':
s.pendingExtras.push(e.trainNumber);
break;
@@ -1032,7 +1278,7 @@ function protoCard(
kind: { kind: string; geometry?: string; facility?: string },
variant: number | undefined,
): TrackCard | null {
const base = { standing: [], facility: null, modifiers: [] };
const base = { standing: [], facility: null, modifiers: [], enhancements: [] };
if (kind.kind === 'track') {
const geometry = kind.geometry as TrackGeometry;
@@ -1117,6 +1363,122 @@ function adjacentFacilityCoord(area: OfficeArea, coord: GridCoord): GridCoord |
return null;
}
/**
* The three defensive Enhancements protect against cards that only exist in a multiplayer deck, so
* their placement and state are implemented and their effect is read at the point of attack:
*
* - **Facing Point Locks** — prevents `Derail` being played on you (Action card).
* - **Water Column** — lets you remove a Watertower from your district (Space-use card).
* - **Overpass** — removes the restrictions of a played Railroad Crossing (Action card).
*
* In solitaire the opponent-directed cards are not in the deck (Q6), so these never fire. They are
* queried here rather than being special-cased at each attack site.
*/
export function hasDistrictEnhancement(area: OfficeArea, key: string): boolean {
return [...area.grid.values()].some((c) => c.enhancements.includes(key));
}
/** Facing Point Locks blocks a Derail played at this district. */
export function isProtectedFromDerail(area: OfficeArea): boolean {
return hasDistrictEnhancement(area, 'facingPointLocks');
}
/** A Water Column lets its owner clear a Watertower off their own grid. */
export function watertowersRemovable(area: OfficeArea): GridCoord[] {
if (!hasDistrictEnhancement(area, 'waterColumn')) return [];
const out: GridCoord[] = [];
for (const [key, card] of area.grid) {
if (card.geometry.kind !== 'spaceUse' || card.geometry.key !== 'watertower') continue;
const [row, col] = key.split(',').map(Number);
out.push({ row: row!, col: col! });
}
return out;
}
/**
* Enhancements have per-card placement rules (implications.md §7):
* - Interlocking, Water Column, Telegraph → a Running Track straight
* - Yard Office, Small Yard → a Secondary Track straight
* - Telephone / Radio → stacked on the card below them
* - Facing Point Locks → needs an Interlocking in the district
* - ABS Signals → a Mainline card, not the Office Area
*/
export function checkEnhancementPlacement(
s: GameState,
area: OfficeArea,
key: string,
placement: GridCoord,
): RejectionCode | null {
const rule = enhancementRule(key);
if (!rule) return 'NOT_IMPLEMENTED';
// ABS Signals goes on a Mainline card; `placement.col` names which one.
if (rule.placement === 'mainlineCard') {
const node = s.division.nodes[placement.col];
return node && node.kind === 'mainline' ? null : 'NOT_CONNECTED';
}
const card = area.grid.get(coordKey(placement));
if (!card) return 'NOT_CONNECTED';
if (card.enhancements.includes(key)) return 'OPTION_ALREADY_CHOSEN';
if (rule.requiresOnSameCard && !card.enhancements.includes(rule.requiresOnSameCard)) {
return 'NOT_CONNECTED';
}
if (rule.requiresInDistrict) {
const present = [...area.grid.values()].some((c) =>
c.enhancements.includes(rule.requiresInDistrict!),
);
if (!present) return 'NOT_CONNECTED';
}
const onRunning = placement.row === area.runningRow;
const isStraight =
card.geometry.kind === 'track' && card.geometry.geometry === 'straight';
switch (rule.placement) {
case 'runningTrackStraight':
return onRunning && isStraight ? null : 'NOT_CONNECTED';
case 'secondaryTrackStraight':
return !onRunning && isStraight ? null : 'NOT_CONNECTED';
case 'onCard':
return null;
default:
return 'NOT_CONNECTED';
}
}
/** Removes a played card from its owner's hand and sends it to the Salvage Yard. */
function spendCard(s: GameState, player: PlayerIndex, cardId: CardId): void {
s.decks.hands.set(player, (s.decks.hands.get(player) ?? []).filter((c) => c !== cardId));
s.decks.salvageYard.push(cardId);
}
/** A track piece from the player's own supply, at the chosen rotation. */
function protoTrackCard(
geometry: TrackGeometry,
hand: Hand,
variant: number | undefined,
): TrackCard | null {
const options = variantsFor(geometry);
const v = options[variant ?? 0];
if (!v) return null;
return {
geometry: {
kind: 'track',
geometry,
...(v.axis ? { axis: v.axis } : {}),
...(v.turnout ? { turnout: v.turnout } : {}),
...(hand !== 'none' ? { hand } : {}),
},
baseOperationalRail: geometry !== 'turnout',
standing: [],
facility: null,
modifiers: [],
enhancements: [],
};
}
/**
* Q4 — would building `kind` here conflict with something already in the district?
* The relation is symmetric, so checking either direction is enough.