v0.4.0 — multiplayer Phases 0 and 1: seat and player split apart, turn state per player, the page behind a Session, and eight seat/player mix-ups fixed with tests that fail without them

This commit is contained in:
Jesse
2026-08-13 14:06:02 -04:00
parent 216006b091
commit 49f8504b05
34 changed files with 1743 additions and 526 deletions
+74 -50
View File
@@ -43,13 +43,14 @@ import type {
OfficeArea,
PlayerIndex,
RollingStock,
SeatIndex,
TrackArc,
TrackCard,
TrayId,
TurnoutOrientation,
} from './state.ts';
import { createRng } from './rng.ts';
import { carsOn, coordKey, isOperationalRail, spaceOn } from './state.ts';
import { carsOn, coordKey, isOperationalRail, playerAtSeat, seatOf, spaceOn, turnOf } from './state.ts';
import type { MoveBlock, Occupancy, Port } from './track.ts';
import {
canDropCarsAt,
@@ -70,9 +71,21 @@ export type ApplyResult =
// Lookup helpers
// ---------------------------------------------------------------------------
/**
* The Office Area belonging to a PLAYER — that is, the one at the seat they currently occupy.
*
* Goes through `seatOf` rather than indexing directly, which is the whole point of the seat/player
* split: today seating is the identity mapping so this is exactly what it always was, and under
* Employee Rotation it follows the player to their new chair without a single caller changing.
*/
export function areaOf(s: GameState, player: PlayerIndex): OfficeArea {
const a = s.officeAreas.get(player);
if (!a) throw new Error(`no Office Area for player ${player}`);
return areaAtSeat(s, seatOf(s, player));
}
/** The Office Area at a POSITION on the Division, regardless of who is sitting there. */
export function areaAtSeat(s: GameState, seat: SeatIndex): OfficeArea {
const a = s.officeAreas.get(seat);
if (!a) throw new Error(`no Office Area at seat ${seat}`);
return a;
}
@@ -176,7 +189,7 @@ export function facilityCarType(f: Facility): CarType | null {
export function hasSwitchOption(s: GameState, player: PlayerIndex): boolean {
for (const tray of s.trays.values()) {
if (tray.position.at === 'grid' && tray.position.owner === player) return true;
if (tray.position.at === 'grid' && tray.position.seat === seatOf(s, player)) return true;
}
return false;
}
@@ -353,6 +366,11 @@ function trainAtOfficeWith(
* and may do anything the general rules allow. Every restriction below is keyed off the CARD, so a
* crew is unaffected by all of them.
*/
/** Which district a tray is standing in; 0 when it is out on the Division. */
function trayySeat(tray: CrewTray): SeatIndex {
return tray.position.at === 'grid' ? tray.position.seat : 0;
}
function rulesOf(tray: CrewTray): TrainRules {
if (tray.trainNumber === null) return {};
return trainProfile(tray.trainNumber, tray.trainIsExtra)?.rules ?? {};
@@ -374,12 +392,13 @@ const isFreight = (c: RollingStock): boolean => c.type !== 'coach' && c.type !==
*/
function freightBudgetLeft(
s: GameState,
player: PlayerIndex,
tray: CrewTray,
at: GridCoord,
wanted: number,
): boolean {
if (!rulesOf(tray).oneFreightPerLocation) return true;
const already = s.turn.freightWorked[freightWorkedKey(tray.id, at)] ?? 0;
const already = turnOf(s, player).freightWorked[freightWorkedKey(tray.id, at)] ?? 0;
return already + wanted <= 1;
}
@@ -403,6 +422,7 @@ function switchingRefusal(tray: CrewTray): RejectionCode | null {
*/
function spendFreightBudget(
s: GameState,
player: PlayerIndex,
tray: CrewTray,
at: GridCoord,
stock: readonly RollingStock[],
@@ -410,8 +430,9 @@ function spendFreightBudget(
if (!rulesOf(tray).oneFreightPerLocation) return;
const n = stock.filter(isFreight).length;
if (n === 0) return;
const turn = turnOf(s, player);
const key = freightWorkedKey(tray.id, at);
s.turn.freightWorked[key] = (s.turn.freightWorked[key] ?? 0) + n;
turn.freightWorked[key] = (turn.freightWorked[key] ?? 0) + n;
}
/** Trains that may not be worked by Porters at all (§7): the Military train and the Director's car. */
@@ -426,8 +447,7 @@ function refusesPassengers(tray: CrewTray): boolean {
*/
function refusesThisOffice(s: GameState, player: PlayerIndex, tray: CrewTray): boolean {
if (!rulesOf(tray).terminalsOnly) return false;
const area = s.officeAreas.get(player);
return !area || area.tier !== 'terminal';
return areaOf(s, player).tier !== 'terminal';
}
/**
@@ -466,7 +486,7 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
// -- Local Operations -----------------------------------------------------
case 'localOps.choose':
if (!inPhase(s, 'localOps')) return 'WRONG_PHASE';
if (s.turn.option !== null) return 'OPTION_ALREADY_CHOSEN';
if (turnOf(s, player).option !== null) return 'OPTION_ALREADY_CHOSEN';
// An option with no possible follow-up is not available at all (§6).
if (i.option === 'freightAgent' && !hasFreightAgentOption(s, player)) return 'NO_SUCH_FACILITY';
if (i.option === 'switch' && !hasSwitchOption(s, player)) return 'NO_SUCH_TRAY';
@@ -474,8 +494,8 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
case 'switch.move': {
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';
if (turnOf(s, player).option !== 'switch') return 'OPTION_NOT_CHOSEN';
if (turnOf(s, player).movesRemaining < 1) return 'NO_MOVES_REMAINING';
const tray = s.trays.get(i.trayId);
if (!tray) return 'NO_SUCH_TRAY';
const noSwitch = switchingRefusal(tray);
@@ -496,14 +516,14 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
if (rules.dropOnly) return 'PICKUP_NOT_ALLOWED';
if (rules.pickUpEmptiesOnly && dest.couples.some((c) => c.loaded)) return 'EMPTIES_ONLY';
const freight = dest.couples.filter(isFreight).length;
if (freight > 0 && !freightBudgetLeft(s, tray, i.to, freight)) return 'FREIGHT_WORKED_HERE';
if (freight > 0 && !freightBudgetLeft(s, player, tray, i.to, freight)) return 'FREIGHT_WORKED_HERE';
}
return null;
}
case 'switch.dropCars': {
if (!inPhase(s, 'localOps')) return 'WRONG_PHASE';
if (s.turn.option !== 'switch') return 'OPTION_NOT_CHOSEN';
if (turnOf(s, player).option !== 'switch') return 'OPTION_NOT_CHOSEN';
const tray = s.trays.get(i.trayId);
if (!tray) return 'NO_SUCH_TRAY';
const noSwitch = switchingRefusal(tray);
@@ -544,7 +564,7 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
return 'COACH_MUST_STAY';
}
const droppedFreight = cut.filter(isFreight).length;
if (droppedFreight > 0 && !freightBudgetLeft(s, tray, here, droppedFreight)) {
if (droppedFreight > 0 && !freightBudgetLeft(s, player, tray, here, droppedFreight)) {
return 'FREIGHT_WORKED_HERE';
}
return canDropCarsAt(areaOf(s, player), here, i.count) ? null : 'CANNOT_DROP_HERE';
@@ -552,8 +572,8 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
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';
if (turnOf(s, player).option !== 'switch') return 'OPTION_NOT_CHOSEN';
if (turnOf(s, player).movesRemaining < 1) return 'NO_MOVES_REMAINING';
const tray = s.trays.get(i.trayId);
if (!tray) return 'NO_SUCH_TRAY';
const noSwitch = switchingRefusal(tray);
@@ -573,26 +593,26 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
case 'switch.end':
if (!inPhase(s, 'localOps')) return 'WRONG_PHASE';
return s.turn.option === 'switch' ? null : 'OPTION_NOT_CHOSEN';
return turnOf(s, player).option === 'switch' ? null : 'OPTION_NOT_CHOSEN';
// -- Draw a card ----------------------------------------------------------
case 'draw.fromHomeOffice':
if (!inPhase(s, 'localOps')) return 'WRONG_PHASE';
if (s.turn.option !== 'draw') return 'OPTION_NOT_CHOSEN';
if (s.turn.drawnThisTurn) return 'OPTION_ALREADY_CHOSEN';
if (turnOf(s, player).option !== 'draw') return 'OPTION_NOT_CHOSEN';
if (turnOf(s, player).drawnThisTurn) return 'OPTION_ALREADY_CHOSEN';
if (s.decks.homeOffice.length === 0) return 'DECK_EMPTY';
return null;
case 'draw.fromDepartment':
if (!inPhase(s, 'localOps')) return 'WRONG_PHASE';
if (s.turn.option !== 'draw') return 'OPTION_NOT_CHOSEN';
if (s.turn.drawnThisTurn) return 'OPTION_ALREADY_CHOSEN';
if (turnOf(s, player).option !== 'draw') return 'OPTION_NOT_CHOSEN';
if (turnOf(s, player).drawnThisTurn) return 'OPTION_ALREADY_CHOSEN';
if (i.slot < 0 || i.slot > 2) return 'SLOT_EMPTY';
return (s.decks.departments[i.slot]?.length ?? 0) > 0 ? null : 'SLOT_EMPTY';
case 'card.play': {
if (!inPhase(s, 'localOps')) return 'WRONG_PHASE';
if (s.turn.option !== 'draw') return 'OPTION_NOT_CHOSEN';
if (turnOf(s, player).option !== 'draw') return 'OPTION_NOT_CHOSEN';
const hand = s.decks.hands.get(player) ?? [];
if (!hand.includes(i.cardId)) return 'CARD_NOT_IN_HAND';
return checkPlay(s, player, i.cardId, i.placement, i.variant, i.node);
@@ -608,7 +628,7 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
case 'mainline.modify': {
if (!inPhase(s, 'localOps')) return 'WRONG_PHASE';
if (s.turn.option !== 'draw') return 'OPTION_NOT_CHOSEN';
if (turnOf(s, player).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';
@@ -646,8 +666,8 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
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';
if (turnOf(s, player).option !== 'switch') return 'OPTION_NOT_CHOSEN';
if (turnOf(s, player).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';
@@ -677,7 +697,7 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
case 'draw.end': {
if (!inPhase(s, 'localOps')) return 'WRONG_PHASE';
if (s.turn.option !== 'draw') return 'OPTION_NOT_CHOSEN';
if (turnOf(s, player).option !== 'draw') return 'OPTION_NOT_CHOSEN';
// §6.2 — "the player must reduce his hand to no more than three cards".
const hand = s.decks.hands.get(player) ?? [];
const limit = s.decks.redFlags.get(player) ? HAND_LIMIT + 1 : HAND_LIMIT;
@@ -687,8 +707,8 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
// -- Freight Agent --------------------------------------------------------
case 'freightAgent.stockOutbound': {
if (!inPhase(s, 'localOps')) return 'WRONG_PHASE';
if (s.turn.option !== 'freightAgent') return 'OPTION_NOT_CHOSEN';
if (s.turn.freightAgentUsed) return 'OPTION_ALREADY_CHOSEN';
if (turnOf(s, player).option !== 'freightAgent') return 'OPTION_NOT_CHOSEN';
if (turnOf(s, player).freightAgentUsed) return 'OPTION_ALREADY_CHOSEN';
const f = facilityAt(s, player, i.at);
if (!f) return 'NO_SUCH_FACILITY';
if (!f.allows.outbound) return 'NO_SUCH_FACILITY';
@@ -703,8 +723,8 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
case 'freightAgent.clearInbound': {
if (!inPhase(s, 'localOps')) return 'WRONG_PHASE';
if (s.turn.option !== 'freightAgent') return 'OPTION_NOT_CHOSEN';
if (s.turn.freightAgentUsed) return 'OPTION_ALREADY_CHOSEN';
if (turnOf(s, player).option !== 'freightAgent') return 'OPTION_NOT_CHOSEN';
if (turnOf(s, player).freightAgentUsed) return 'OPTION_ALREADY_CHOSEN';
const f = facilityAt(s, player, i.at);
if (!f) return 'NO_SUCH_FACILITY';
return f.inboundBox[i.index] ? null : 'BOX_EMPTY';
@@ -715,12 +735,12 @@ export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCo
// §6.3 offers three things the Freight Agent may do and requires none of them. Ending with the
// action unspent is a wasted Stage, which is the player's to waste — the alternative was
// forcing an unjam that destroys a load.
return s.turn.option === 'freightAgent' ? null : 'OPTION_NOT_CHOSEN';
return turnOf(s, player).option === 'freightAgent' ? null : 'OPTION_NOT_CHOSEN';
case 'freightAgent.unjam': {
if (!inPhase(s, 'localOps')) return 'WRONG_PHASE';
if (s.turn.option !== 'freightAgent') return 'OPTION_NOT_CHOSEN';
if (s.turn.freightAgentUsed) return 'OPTION_ALREADY_CHOSEN';
if (turnOf(s, player).option !== 'freightAgent') return 'OPTION_NOT_CHOSEN';
if (turnOf(s, player).freightAgentUsed) return 'OPTION_ALREADY_CHOSEN';
const f = facilityAt(s, player, i.at);
if (!f) return 'NO_SUCH_FACILITY';
if (i.from === 'menAtWork') return f.menAtWork?.[i.index] ? null : 'BOX_EMPTY';
@@ -1051,7 +1071,7 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
trayId: i.trayId,
from,
to: i.to,
movesRemaining: s.turn.movesRemaining - 1,
movesRemaining: turnOf(s, player).movesRemaining - 1,
/**
* A TRAIN THAT BACKS UP HAS NOT TURNED AROUND.
*
@@ -1366,19 +1386,22 @@ function findTimetableSlot(s: GameState, from: number): number | null {
export function reduce(s: GameState, e: GameEvent): void {
switch (e.type) {
case 'localOpsOptionChosen':
s.turn.option = e.option;
turnOf(s, e.player).option = e.option;
break;
case 'trayMoved': {
const tray = s.trays.get(e.trayId)!;
const owner = tray.position.at === 'grid' ? tray.position.owner : 0;
tray.position = { at: 'grid', owner, coord: e.to };
// A tray moving stays in the district it was already in — the seat does not change.
const seat = tray.position.at === 'grid' ? tray.position.seat : 0;
tray.position = { at: 'grid', seat, coord: e.to };
if (e.facing) tray.facing = e.facing;
s.turn.movesRemaining = e.movesRemaining;
// Only the player sitting in this district can be switching this tray, so the Moves come off
// their turn. The event carries no player of its own.
turnOf(s, playerAtSeat(s, seat)).movesRemaining = e.movesRemaining;
// An A/D track is held only while the train is actually standing at the Office (§2.1).
// Leaving it out of sync means the Office looks permanently full and every arrival collides.
const area = areaOf(s, owner);
const area = areaAtSeat(s, seat);
const atOffice =
e.to.row === area.officeCoord.row && e.to.col === area.officeCoord.col;
area.adOccupancy = area.adOccupancy.filter((t) => t !== e.trayId);
@@ -1388,7 +1411,7 @@ export function reduce(s: GameState, e: GameEvent): void {
case 'carsCoupled': {
const tray = s.trays.get(e.trayId)!;
const area = areaOf(s, tray.position.at === 'grid' ? tray.position.owner : 0);
const area = areaAtSeat(s, tray.position.at === 'grid' ? tray.position.seat : 0);
if (e.toNose) {
// Cars taken on the nose go AHEAD of the engine — "pushing them into the Facility" — so the
// engine is no longer at the front and its index has to follow. It never did, so a crew that
@@ -1407,7 +1430,7 @@ export function reduce(s: GameState, e: GameEvent): void {
card.standing = [];
if (card.facility) card.facility.industryTrack.cars = [];
}
spendFreightBudget(s, tray, e.at, e.stock);
spendFreightBudget(s, playerAtSeat(s, trayySeat(tray)), tray, e.at, e.stock);
break;
}
@@ -1418,13 +1441,14 @@ export function reduce(s: GameState, e: GameEvent): void {
// to use one: §8.2 will not let a train leave the Office with cars in front of its engine.
tray.engineAt = 0;
// "Spends one move in the yard" — the sort costs a Move.
s.turn.movesRemaining = Math.max(0, s.turn.movesRemaining - 1);
const sorter = turnOf(s, playerAtSeat(s, trayySeat(tray)));
sorter.movesRemaining = Math.max(0, sorter.movesRemaining - 1);
break;
}
case 'carsDropped': {
const tray = s.trays.get(e.trayId)!;
const area = areaOf(s, tray.position.at === 'grid' ? tray.position.owner : 0);
const area = areaAtSeat(s, tray.position.at === 'grid' ? tray.position.seat : 0);
if (e.fromNose) {
// Off the front: everything ahead of the engine shortens, so the engine moves up by that
// much. This is how a train that took cars onto its nose gets back to being made up.
@@ -1437,7 +1461,7 @@ export function reduce(s: GameState, e: GameEvent): void {
const card = area.grid.get(coordKey(e.at));
// On a Facility card the industry track is where cars stand (§9.3).
if (card) carsOn(card).push(...e.stock);
spendFreightBudget(s, tray, e.at, e.stock);
spendFreightBudget(s, playerAtSeat(s, trayySeat(tray)), tray, e.at, e.stock);
break;
}
@@ -1469,7 +1493,7 @@ export function reduce(s: GameState, e: GameEvent): void {
else if (e.slot !== undefined) s.decks.departments[e.slot]!.pop();
hand.push(e.cardId);
s.decks.hands.set(e.player, hand);
s.turn.drawnThisTurn = true;
turnOf(s, e.player).drawnThisTurn = true;
break;
}
@@ -1532,7 +1556,7 @@ export function reduce(s: GameState, e: GameEvent): void {
if (track) track.cars.push(...e.stock);
else card.standing.push(...e.stock);
}
s.turn.movesRemaining -= 1;
turnOf(s, e.player).movesRemaining -= 1;
spendCard(s, e.player, e.cardId);
break;
}
@@ -1588,7 +1612,7 @@ export function reduce(s: GameState, e: GameEvent): void {
if (idx >= 0) s.yards.divisionYard.splice(idx, 1);
refillDivisionYardIfEmpty(s);
f.outboundBox.push(e.stock);
s.turn.freightAgentUsed = true;
turnOf(s, e.player).freightAgentUsed = true;
break;
}
@@ -1597,7 +1621,7 @@ export function reduce(s: GameState, e: GameEvent): void {
const idx = f.inboundBox.findIndex((c) => c.type === e.stock.type && c.loaded === e.stock.loaded);
if (idx >= 0) f.inboundBox.splice(idx, 1);
s.yards.classificationYard.push(e.stock);
s.turn.freightAgentUsed = true;
turnOf(s, e.player).freightAgentUsed = true;
break;
}
@@ -1612,7 +1636,7 @@ export function reduce(s: GameState, e: GameEvent): void {
if (idx >= 0) box.splice(idx, 1);
}
s.yards.classificationYard.push(e.stock);
s.turn.freightAgentUsed = true;
turnOf(s, e.player).freightAgentUsed = true;
break;
}
@@ -1753,7 +1777,7 @@ export function reduce(s: GameState, e: GameEvent): void {
case 'phaseEnded':
// The actor has finished; the phase driver moves on to the next player.
if (e.phase !== 'redFlag') s.turn.done = true;
if (e.phase !== 'redFlag') turnOf(s, e.player).done = true;
break;
case 'clearanceGiven':