Expand game engine, replay, tests, and documentation
This commit is contained in:
+136
-12
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user