Expand game engine, replay, tests, and documentation

This commit is contained in:
Jesse
2026-07-31 08:10:54 -04:00
parent e7bc07df85
commit 401b879140
30 changed files with 2253 additions and 552 deletions
+136 -12
View File
@@ -19,10 +19,12 @@ import {
HAND_LIMIT,
LABORER_ACTIONS_PER_LOAD,
MAX_CONSIST,
industryProfile,
modifierProfile,
nextOfficeTier,
officeProfile,
} from './content.ts';
import type { CarType, FreightKind } from './content.ts';
import type { CarType, FreightKind, ModifierKind, TrackGeometry } from './content.ts';
import type { GameEvent } from './events.ts';
import type { Intent, RejectionCode } from './intents.ts';
import type {
@@ -140,7 +142,7 @@ export function hasFreightAgentOption(s: GameState, player: PlayerIndex): boolea
*/
export function facilityCarType(f: Facility): CarType | null {
if (f.kind !== 'freight') return f.kind === 'passenger' ? 'coach' : null;
return FREIGHT_PROFILES.find((p) => p.kind === f.subtype)?.carType ?? null;
return FREIGHT_PROFILES.find((p) => p.kind === f.subtype)?.carTypes[0] ?? null;
}
export function hasSwitchOption(s: GameState, player: PlayerIndex): boolean {
@@ -363,6 +365,14 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
return null;
}
case 'newTrain.secondSection': {
if (!inPhase(s, 'newTrain')) return 'WRONG_PHASE';
// Only on a train that is actually due out this Stage — a second section follows a first.
if (s.timetable[s.clock.stage - 1] !== i.trainNumber) return 'NO_SUCH_TRAY';
if (s.freeTrays.length === 0) return 'NO_SUCH_TRAY';
return null;
}
case 'newTrain.passCar': {
if (!inPhase(s, 'newTrain')) return 'WRONG_PHASE';
const tray = s.trays.get(i.trayId);
@@ -445,17 +455,39 @@ function checkPlay(
return next === card.kind.tier ? null : 'NOT_UPGRADEABLE';
}
case 'track':
if (!placement) return 'NO_PLACEMENT';
{
const proto = protoCard(card.kind, variant);
if (!proto) return 'NO_PLACEMENT';
return canPlaceAt(area, placement, proto) ? null : 'NOT_CONNECTED';
}
case 'freightFacility': {
if (!placement) return 'NO_PLACEMENT';
// Q4 — a lockout prevents BUILDING both in one district. Each locked pair is a producer and
// the consumer of the same commodity, so this forces traffic to flow between districts.
if (isLockedOut(area, card.kind.facility)) return 'FACILITY_LOCKED';
const proto = protoCard(card.kind, variant);
if (!proto) return 'NO_PLACEMENT';
return canPlaceAt(area, placement, proto) ? null : 'NOT_CONNECTED';
}
case 'modifier': {
if (!placement) return 'NO_PLACEMENT';
// §9 — a Modifier is not track; it sits adjacent to a Facility.
return area.grid.has(coordKey(placement)) ? 'NOT_CONNECTED' : null;
if (area.grid.has(coordKey(placement))) return 'NOT_CONNECTED';
// §9 — a Modifier is not track. It must sit adjacent to a Facility (one of the nine nearby
// 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 'mainlineModifier':
case 'maneuver':
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
// nothing, which is exactly the bug that made Modifiers dead weight for weeks.
return 'NOT_IMPLEMENTED';
default:
// Train cards go to the timetable; the phase driver schedules them.
return null;
@@ -534,8 +566,8 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
},
];
case 'draw.fromDepartment':
return [
case 'draw.fromDepartment': {
const events: GameEvent[] = [
{
type: 'cardDrawn',
player,
@@ -544,6 +576,15 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
cardId: s.decks.departments[i.slot]!,
},
];
// §6.2 — "If any of the Department decks is empty, draw a Home Office card and place it in
// the empty spot." Without this the three face-up slots empty out permanently and the market
// silently disappears from the game.
const refill = s.decks.homeOffice[s.decks.homeOffice.length - 1];
if (refill) {
events.push({ type: 'departmentRefilled', slot: i.slot, cardId: refill });
}
return events;
}
case 'card.play': {
const card = s.cards.get(i.cardId)!;
@@ -566,6 +607,11 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
to: card.kind.tier,
});
}
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.
events.push({ type: 'extraQueued', player, trainNumber: card.kind.number });
}
if (card.kind.kind === 'timetabledTrain') {
// §7 — roll 1D12 for the timetable slot; if occupied, work down the column, wrapping at
// the bottom. Without this no train ever runs, so nothing can ever be earned.
@@ -621,6 +667,9 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
case 'newTrain.passCar':
return [{ type: 'carPassed', player, trayId: i.trayId }];
case 'newTrain.secondSection':
return [{ type: 'secondSectionOrdered', player, trainNumber: i.trainNumber }];
case 'mainline.clearance':
return [
{
@@ -748,6 +797,11 @@ export function reduce(s: GameState, e: GameEvent): void {
break;
}
case 'departmentRefilled':
s.decks.homeOffice.pop();
s.decks.departments[e.slot] = e.cardId;
break;
case 'cardDrawn': {
const hand = s.decks.hands.get(e.player) ?? [];
if (e.source === 'homeOffice') s.decks.homeOffice.pop();
@@ -771,7 +825,16 @@ export function reduce(s: GameState, e: GameEvent): void {
extendLimitsIfNeeded(area, e.placement);
}
} else if (e.placement && card.kind.kind === 'modifier') {
s.decks.salvageYard.push(e.cardId);
// A Modifier stays on the board beside its Facility, and its effect is applied. It used to
// be discarded straight to the Salvage Yard, so playing one did literally nothing.
area.grid.set(coordKey(e.placement), {
geometry: { kind: 'modifier', modifier: card.kind.modifier },
baseOperationalRail: false,
standing: [],
facility: null,
modifiers: [],
});
applyModifier(area, e.placement, card.kind.modifier);
} else if (card.kind.kind !== 'office') {
s.decks.salvageYard.push(e.cardId);
}
@@ -786,7 +849,7 @@ export function reduce(s: GameState, e: GameEvent): void {
if (officeCard?.facility) {
const p = officeProfile(e.to);
officeCard.facility.porters = p.porters;
officeCard.facility.capacity = { outbound: p.greenSlots, inbound: p.redSlots };
officeCard.facility.capacity = { outbound: p.passengerOut, inbound: p.passengerIn };
officeCard.facility.allows = { outbound: p.isPassengerFacility, inbound: p.isPassengerFacility };
}
break;
@@ -834,6 +897,14 @@ export function reduce(s: GameState, e: GameEvent): void {
break;
}
case 'extraQueued':
s.pendingExtras.push(e.trainNumber);
break;
case 'secondSectionOrdered':
s.pendingSecondSections.push(e.trainNumber);
break;
case 'trainScheduled':
s.timetable[e.slot] = e.trainNumber;
s.rngState = e.rngState;
@@ -964,7 +1035,7 @@ function protoCard(
const base = { standing: [], facility: null, modifiers: [] };
if (kind.kind === 'track') {
const geometry = kind.geometry as 'straight' | 'turnout' | 'runAround';
const geometry = kind.geometry as TrackGeometry;
const options = variantsFor(geometry);
const v = options[variant ?? 0];
if (!v) return null;
@@ -1013,15 +1084,68 @@ function buildFreightFacility(kind: FreightKind): Facility {
},
outboundBox: [],
inboundBox: [],
capacity: { outbound: p.outboundCapacity, inbound: p.inboundCapacity },
// The design gives every industry ONE car out and ONE loader; capacity is grown by the
// industry-specific modifier cards, not printed large on the industry itself.
capacity: { outbound: p.baseOut, inbound: p.baseIn },
menAtWork: [null, null, null],
industryTrack: { length: p.industryTrackLength, cars: [] },
laborers: p.laborers,
industryTrack: { length: Math.max(1, p.baseOut + p.baseIn), cars: [] },
laborers: p.baseLoaders,
porters: 0,
usedThisStage: { laborers: 0, porters: 0 },
};
}
/**
* The Facility a Modifier at `coord` would serve — any of the nine nearby spots (§9).
*
* SIMPLIFICATION, deliberate and flagged: §9 says that when a Modifier touches two Facilities its
* effect may be used on only one per Stage. This applies the effect permanently to the first
* Facility found instead. Modelling the per-Stage choice needs an extra decision point in the
* Load/Unload phase and is not worth it until the mechanic has been played.
*/
function adjacentFacilityCoord(area: OfficeArea, coord: GridCoord): GridCoord | null {
// Q7 — nothing exists above the Running Track, so §9's "nine nearby spots" is really six.
if (coord.row > area.runningRow) return null;
for (let dr = -1; dr <= 1; dr++) {
for (let dc = -1; dc <= 1; dc++) {
if (dr === 0 && dc === 0) continue;
const c = { row: coord.row + dr, col: coord.col + dc };
if (c.row > area.runningRow) continue;
if (area.grid.get(coordKey(c))?.facility) return c;
}
}
return null;
}
/**
* Q4 — would building `kind` here conflict with something already in the district?
* The relation is symmetric, so checking either direction is enough.
*/
export function isLockedOut(area: OfficeArea, kind: FreightKind): boolean {
const wanted = industryProfile(kind);
for (const card of area.grid.values()) {
if (card.geometry.kind !== 'facility') continue;
const present = card.geometry.facility;
if (wanted.lockouts.includes(present)) return true;
if (industryProfile(present).lockouts.includes(kind)) return true;
}
return false;
}
/** Applies a Modifier's printed effect to the Facility it was placed beside (content.ts). */
function applyModifier(area: OfficeArea, coord: GridCoord, modifier: ModifierKind): void {
const target = adjacentFacilityCoord(area, coord);
if (!target) return;
const f = area.grid.get(coordKey(target))?.facility;
if (!f) return;
const m = modifierProfile(modifier);
f.capacity.outbound += m.addOut;
f.capacity.inbound += m.addIn;
f.laborers += m.addLoaders;
f.porters += m.addPorters;
if (m.addOut > 0 || m.addIn > 0) f.industryTrack.length += m.addOut + m.addIn;
}
/** §2.1, Gap 4a — extending the Running Track pushes the Limits sign outward. */
function extendLimitsIfNeeded(area: OfficeArea, placed: GridCoord): void {
if (placed.row !== area.runningRow) return;