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:
+48
-31
@@ -36,10 +36,10 @@ import type { Direction } from './content.ts';
|
||||
import type { GameEvent } from './events.ts';
|
||||
// `trainNeedingCars` lives in apply.ts beside `check`'s copy of the same question, so the phase and
|
||||
// the legality test cannot disagree about which train is being assembled.
|
||||
import { areaOf, trainNeedingCars } from './apply.ts';
|
||||
import { areaAtSeat, areaOf, trainNeedingCars } from './apply.ts';
|
||||
import { legalActions } from './legal.ts';
|
||||
import type { CrewTray, DivisionNode, GameState, PlayerIndex, RollingStock, TrayId } from './state.ts';
|
||||
import { coordKey, freshTurn, subdivisions, totalRevenue } from './state.ts';
|
||||
import type { CrewTray, DivisionNode, GameState, PlayerIndex, RollingStock, SeatIndex, TrayId } from './state.ts';
|
||||
import { coordKey, freshTurns, playerAtSeat, subdivisions, totalRevenue, turnOf } from './state.ts';
|
||||
|
||||
export type AdvanceResult = {
|
||||
events: GameEvent[];
|
||||
@@ -61,8 +61,8 @@ function movesForStage(s: GameState): number {
|
||||
// Division navigation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function nodeIndexOfOffice(s: GameState, owner: PlayerIndex): number {
|
||||
return s.division.nodes.findIndex((n) => n.kind === 'office' && n.owner === owner);
|
||||
function nodeIndexOfOffice(s: GameState, seat: SeatIndex): number {
|
||||
return s.division.nodes.findIndex((n) => n.kind === 'office' && n.seat === seat);
|
||||
}
|
||||
|
||||
const step = (d: Direction): number => (d === 'east' ? 1 : -1);
|
||||
@@ -101,31 +101,41 @@ function playerPhase(
|
||||
events: GameEvent[],
|
||||
phase: 'localOps' | 'loadUnload',
|
||||
): AdvanceResult {
|
||||
/**
|
||||
* THE CURSOR STILL WALKS ONE PLAYER AT A TIME.
|
||||
*
|
||||
* Turn state is now per-player, but only the player at `actorOffset` is ever asked to act, so this
|
||||
* behaves exactly as it did when there was a single `TurnState` — which is the point: the model
|
||||
* change is neutral, and letting local work happen off-cursor is a later change to `isActor`
|
||||
* alone (`docs/architecture/multiplayer.md` D19).
|
||||
*/
|
||||
const actor = actorAt(s, s.clock.actorOffset);
|
||||
const turn = turnOf(s, actor);
|
||||
|
||||
// A Freight Agent operation is a whole action in itself, so the turn ends with it (§6.3).
|
||||
if (phase === 'localOps' && s.turn.option === 'freightAgent' && s.turn.freightAgentUsed) {
|
||||
s.turn.done = true;
|
||||
if (phase === 'localOps' && turn.option === 'freightAgent' && turn.freightAgentUsed) {
|
||||
turn.done = true;
|
||||
}
|
||||
// Spending the last Move ends a switching turn without needing an explicit end (§6.1).
|
||||
if (phase === 'localOps' && s.turn.option === 'switch' && s.turn.movesRemaining === 0) {
|
||||
s.turn.done = true;
|
||||
if (phase === 'localOps' && turn.option === 'switch' && turn.movesRemaining === 0) {
|
||||
turn.done = true;
|
||||
}
|
||||
|
||||
if (!s.turn.done) {
|
||||
s.clock.currentActor = actorAt(s, s.clock.actorOffset);
|
||||
if (!turn.done) {
|
||||
s.clock.currentActor = actor;
|
||||
|
||||
// SAFETY NET. If the actor has no legal action at all, the turn ends rather than deadlocking.
|
||||
// This should never fire — an option with no follow-up is already unavailable (§6, apply.ts) —
|
||||
// but a rules gap that stranded a player would otherwise hang the game rather than fail
|
||||
// visibly, and a hung game is far harder to diagnose than a forfeited turn.
|
||||
if (legalActions(s, s.clock.currentActor).length === 0) {
|
||||
s.turn.done = true;
|
||||
turn.done = true;
|
||||
} else {
|
||||
return { events, needsInput: true };
|
||||
}
|
||||
}
|
||||
|
||||
s.clock.actorOffset += 1;
|
||||
s.turn = freshTurn(movesForStage(s));
|
||||
|
||||
if (s.clock.actorOffset >= s.players.length) {
|
||||
return { events: [...events, ...enterPhase(s, nextPhase(phase))], needsInput: false };
|
||||
@@ -158,7 +168,9 @@ function nextPhase(p: GameState['clock']['phase']): GameState['clock']['phase']
|
||||
function enterPhase(s: GameState, phase: GameState['clock']['phase']): GameEvent[] {
|
||||
s.clock.phase = phase;
|
||||
s.clock.actorOffset = 0;
|
||||
s.turn = freshTurn(movesForStage(s));
|
||||
// Every player gets a turn at phase entry, not one at a time as the cursor reaches them. With the
|
||||
// cursor still walking sequentially this is indistinguishable from the old behaviour.
|
||||
s.turns = freshTurns(s.players.length, movesForStage(s));
|
||||
s.clock.currentActor = phase === 'mainline' ? null : actorAt(s, 0);
|
||||
return [
|
||||
{ type: 'phaseBegan', phase },
|
||||
@@ -329,7 +341,8 @@ function mainlinePhase(s: GameState, events: GameEvent[]): AdvanceResult {
|
||||
(tray.position.coord.row === where.coord.row && tray.position.coord.col === where.coord.col));
|
||||
if (moved !== 'expedited' && stillThere) {
|
||||
tray.stopPointClaimed = true;
|
||||
const owner = tray.position.at === 'grid' ? tray.position.owner : 0;
|
||||
// The point goes to whoever is SITTING in the district it stopped in.
|
||||
const owner = tray.position.at === 'grid' ? playerAtSeat(s, tray.position.seat) : 0;
|
||||
const label =
|
||||
tray.position.at === 'grid'
|
||||
? `(${tray.position.coord.row},${tray.position.coord.col})`
|
||||
@@ -431,8 +444,9 @@ function spendDispatchBonus(
|
||||
const mine = tray.trainNumber ?? 99;
|
||||
const theirs = other.trainNumber ?? 99;
|
||||
|
||||
const area = s.officeAreas.get(s.clock.superintendent);
|
||||
if (!area) return 0;
|
||||
// The Fedora is held by a PLAYER, and the dispatch devices are installed in an Office Area, which
|
||||
// is keyed by SEAT. Indexing one with the other is right only while seating is the identity map.
|
||||
const area = areaOf(s, s.clock.superintendent);
|
||||
|
||||
// Best device first — Radio (+12) beats Telephone (+8) beats Telegraph (+4).
|
||||
for (const key of ['radio', 'telephone', 'telegraph'] as const) {
|
||||
@@ -503,8 +517,8 @@ function moveTrain(
|
||||
// Without this a train that arrives at an Office never leaves, holding an A/D track forever and
|
||||
// colliding with every train that follows it.
|
||||
if (tray.position.at === 'grid') {
|
||||
const owner = tray.position.owner;
|
||||
const area = areaOf(s, owner);
|
||||
const seat = tray.position.seat;
|
||||
const area = areaAtSeat(s, seat);
|
||||
// Only a train at the Office itself is eligible; one on Secondary Track is not (§8.1, Gap 2b).
|
||||
if (
|
||||
tray.position.coord.row !== area.officeCoord.row ||
|
||||
@@ -533,7 +547,7 @@ function moveTrain(
|
||||
return 'held';
|
||||
}
|
||||
|
||||
const officeIndex = nodeIndexOfOffice(s, owner);
|
||||
const officeIndex = nodeIndexOfOffice(s, seat);
|
||||
const target = officeIndex + dir;
|
||||
const node = s.division.nodes[target];
|
||||
if (!node) return 'held';
|
||||
@@ -551,7 +565,7 @@ function moveTrain(
|
||||
from: 'the Office',
|
||||
to: 'the Mainline',
|
||||
});
|
||||
awardDeparture(s, owner, tray, events);
|
||||
awardDeparture(s, seat, tray, events);
|
||||
return 'moved';
|
||||
}
|
||||
|
||||
@@ -566,7 +580,7 @@ function moveTrain(
|
||||
*/
|
||||
if (node.kind === 'divisionPoint') {
|
||||
area.adOccupancy = area.adOccupancy.filter((t) => t !== id);
|
||||
awardDeparture(s, owner, tray, events);
|
||||
awardDeparture(s, seat, tray, events);
|
||||
retireTrain(s, id, tray, events);
|
||||
return 'moved';
|
||||
}
|
||||
@@ -654,7 +668,7 @@ function moveTrain(
|
||||
}
|
||||
|
||||
if (dest.kind === 'office') {
|
||||
return arriveAtOffice(s, id, tray, dest.owner, events);
|
||||
return arriveAtOffice(s, id, tray, dest.seat, events);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -758,10 +772,10 @@ function arriveAtOffice(
|
||||
s: GameState,
|
||||
id: TrayId,
|
||||
tray: CrewTray,
|
||||
owner: PlayerIndex,
|
||||
seat: SeatIndex,
|
||||
events: GameEvent[],
|
||||
): MoveOutcome {
|
||||
const area = areaOf(s, owner);
|
||||
const area = areaAtSeat(s, seat);
|
||||
const capacity = officeProfile(area.tier).adTracks;
|
||||
const hasEnhancement = (key: string): boolean =>
|
||||
[...area.grid.values()].some((c) => c.enhancements.includes(key));
|
||||
@@ -773,7 +787,7 @@ function arriveAtOffice(
|
||||
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! } };
|
||||
tray.position = { at: 'grid', seat, coord: { row: row!, col: col! } };
|
||||
events.push({
|
||||
type: 'trainDiverted',
|
||||
trainNumber: tray.trainNumber ?? 0,
|
||||
@@ -799,7 +813,7 @@ function arriveAtOffice(
|
||||
});
|
||||
return 'moved';
|
||||
}
|
||||
collide(s, owner, [id], events, 'no free A/D track', 'the Office');
|
||||
collide(s, playerAtSeat(s, seat), [id], events, 'no free A/D track', 'the Office');
|
||||
return 'moved';
|
||||
}
|
||||
|
||||
@@ -808,7 +822,7 @@ function arriveAtOffice(
|
||||
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 };
|
||||
if (held) held.position = { at: 'grid', seat, coord: area.officeCoord };
|
||||
}
|
||||
area.heldAtLimits = area.heldAtLimits.filter((t) => t !== id);
|
||||
|
||||
@@ -816,12 +830,12 @@ function arriveAtOffice(
|
||||
// is not expecting them (§A.4), so this is a collision too, not a coupling.
|
||||
const officeCard = area.grid.get(coordKey(area.officeCoord));
|
||||
if (officeCard && officeCard.standing.length > 0) {
|
||||
collide(s, owner, [id], events, 'cars fouling the Running Track', 'the Running Track');
|
||||
collide(s, playerAtSeat(s, seat), [id], events, 'cars fouling the Running Track', 'the Running Track');
|
||||
return 'moved';
|
||||
}
|
||||
|
||||
area.adOccupancy.push(id);
|
||||
tray.position = { at: 'grid', owner, coord: area.officeCoord };
|
||||
tray.position = { at: 'grid', seat, coord: area.officeCoord };
|
||||
events.push({
|
||||
type: 'trainArrived',
|
||||
trainNumber: tray.trainNumber ?? 0,
|
||||
@@ -913,11 +927,14 @@ function collide(
|
||||
*/
|
||||
function awardDeparture(
|
||||
s: GameState,
|
||||
owner: PlayerIndex,
|
||||
seat: SeatIndex,
|
||||
tray: CrewTray,
|
||||
events: GameEvent[],
|
||||
): void {
|
||||
if (tray.trainNumber === null) return;
|
||||
// Paid to whoever OCCUPIES the Office the train left, not to the seat's index. Identical today,
|
||||
// and the difference is the whole point of the seat/player split.
|
||||
const owner = playerAtSeat(s, seat);
|
||||
const p = s.players[owner];
|
||||
if (!p) return;
|
||||
p.revenue += 1;
|
||||
|
||||
+74
-50
@@ -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':
|
||||
|
||||
+3
-2
@@ -17,6 +17,7 @@ import { enhancementRule } from './content.ts';
|
||||
import { check, areaOf, destinationsFor } from './apply.ts';
|
||||
import type { Intent } from './intents.ts';
|
||||
import type { GameState, GridCoord, PlayerIndex } from './state.ts';
|
||||
import { seatOf } from './state.ts';
|
||||
import { variantsFor } from './track.ts';
|
||||
|
||||
const CAR_TYPES: readonly CarType[] = ['coach', 'boxcar', 'reefer', 'hopper', 'tank', 'caboose'];
|
||||
@@ -82,7 +83,7 @@ function localOpsCandidates(s: GameState, player: PlayerIndex): Intent[] {
|
||||
|
||||
// -- switch (§6.1)
|
||||
for (const [trayId, tray] of s.trays) {
|
||||
if (tray.position.at !== 'grid' || tray.position.owner !== player) continue;
|
||||
if (tray.position.at !== 'grid' || tray.position.seat !== seatOf(s, player)) continue;
|
||||
const from = tray.position.coord;
|
||||
for (const reverse of [false, true]) {
|
||||
for (const d of destinationsFor(s, player, trayId, from, reverse)) {
|
||||
@@ -113,7 +114,7 @@ function localOpsCandidates(s: GameState, player: PlayerIndex): Intent[] {
|
||||
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;
|
||||
if (tray.position.at !== 'grid' || tray.position.seat !== seatOf(s, player)) continue;
|
||||
const from = tray.position.coord;
|
||||
for (const reverse of [false, true]) {
|
||||
for (const d of destinationsFor(s, player, trayId, from, reverse)) {
|
||||
|
||||
+13
-7
@@ -34,6 +34,7 @@ import type {
|
||||
Card,
|
||||
CardId,
|
||||
DivisionNode,
|
||||
SeatIndex,
|
||||
GameConfig,
|
||||
GameState,
|
||||
OfficeArea,
|
||||
@@ -42,7 +43,7 @@ import type {
|
||||
TrackCard,
|
||||
TrayId,
|
||||
} from './state.ts';
|
||||
import { coordKey, freshTurn } from './state.ts';
|
||||
import { coordKey, freshTurns } from './state.ts';
|
||||
|
||||
export type SetupOptions = {
|
||||
id: string;
|
||||
@@ -129,7 +130,7 @@ export function buildRollingStock(): RollingStock[] {
|
||||
* branch from the opening Stage. The stubs are NOT turnouts: §A.1's directional rule governs
|
||||
* drawn turnout cards only.
|
||||
*/
|
||||
function buildOfficeArea(owner: PlayerIndex): OfficeArea {
|
||||
function buildOfficeArea(seat: SeatIndex): OfficeArea {
|
||||
const row = 0;
|
||||
const officeCoord = { row, col: 0 };
|
||||
const limitsWest = { row, col: -1 };
|
||||
@@ -159,7 +160,7 @@ function buildOfficeArea(owner: PlayerIndex): OfficeArea {
|
||||
grid.set(coordKey(limitsEast), limitsCard());
|
||||
|
||||
return {
|
||||
owner,
|
||||
seat,
|
||||
tier: 'whistlePost',
|
||||
grid,
|
||||
officeCoord,
|
||||
@@ -230,7 +231,7 @@ function buildDivision(players: number, rng: Rng): DivisionNode[] {
|
||||
nodes.push({ kind: 'divisionPoint', side: 'west', holding: [] });
|
||||
for (let p = 0; p < players; p++) {
|
||||
nodes.push(mainline());
|
||||
nodes.push({ kind: 'office', owner: p });
|
||||
nodes.push({ kind: 'office', seat: p });
|
||||
}
|
||||
nodes.push(mainline());
|
||||
nodes.push({ kind: 'divisionPoint', side: 'east', holding: [] });
|
||||
@@ -250,8 +251,12 @@ export function createGame(opts: SetupOptions): GameState {
|
||||
|
||||
const players = playerNames.map((name, index) => ({ index, name, revenue: 0 }));
|
||||
|
||||
const officeAreas = new Map<PlayerIndex, OfficeArea>();
|
||||
for (let p = 0; p < playerCount; p++) officeAreas.set(p, buildOfficeArea(p));
|
||||
// Offices are keyed by SEAT — a fixed position in the west-to-east chain. `seating` maps seats to
|
||||
// the players occupying them, and starts as the identity mapping, which is what makes the
|
||||
// seat/player split behaviour-neutral. Employee Rotation would rotate this array and nothing else.
|
||||
const officeAreas = new Map<SeatIndex, OfficeArea>();
|
||||
for (let seat = 0; seat < playerCount; seat++) officeAreas.set(seat, buildOfficeArea(seat));
|
||||
const seating: PlayerIndex[] = Array.from({ length: playerCount }, (_, seat) => seat);
|
||||
|
||||
// §4.4 - highest D12 takes the Eastern Division Point; §4.5 - highest begins as Superintendent.
|
||||
// Both rolls are drawn even in solitaire so the RNG stream stays identical across player counts.
|
||||
@@ -334,6 +339,7 @@ export function createGame(opts: SetupOptions): GameState {
|
||||
seed,
|
||||
rngState: rng.getState(),
|
||||
players,
|
||||
seating,
|
||||
division: { nodes: buildDivision(playerCount, rng) },
|
||||
officeAreas,
|
||||
trays: new Map(),
|
||||
@@ -354,7 +360,7 @@ export function createGame(opts: SetupOptions): GameState {
|
||||
superintendent,
|
||||
actorOffset: 0,
|
||||
},
|
||||
turn: freshTurn(MOVES_PER_LOCAL_OPS),
|
||||
turns: freshTurns(playerCount, MOVES_PER_LOCAL_OPS),
|
||||
movedThisPhase: new Set(),
|
||||
collisionsToday: 0,
|
||||
status: 'active',
|
||||
|
||||
+76
-12
@@ -24,6 +24,22 @@ import { officeProfile } from './content.ts';
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type PlayerIndex = number;
|
||||
|
||||
/**
|
||||
* A SEAT at the table — a fixed position in the west-to-east chain of Offices (§4.3).
|
||||
*
|
||||
* NOT the same thing as a `PlayerIndex`, even though the two are equal in every game today. An
|
||||
* Office is a place: it sits between two Mainline cards and never moves. A player OCCUPIES a seat,
|
||||
* and Employee Rotation (Appendix B) moves every player one seat left at the end of each Day while
|
||||
* their Revenue and the Fedora travel with them.
|
||||
*
|
||||
* So: offices, districts and grid positions are keyed by SEAT; hands, Revenue, the Superintendent
|
||||
* and whose turn it is are keyed by PLAYER. `s.seating` maps one to the other, and both are plain
|
||||
* numbers, so the distinction is carried by naming and by the accessors rather than by the type
|
||||
* system — `areaOf(s, player)` and `areaAtSeat(s, seat)` are the two doors, and code should use them
|
||||
* rather than reaching into `officeAreas` directly.
|
||||
*/
|
||||
export type SeatIndex = number;
|
||||
export type CardId = string;
|
||||
export type TrayId = string;
|
||||
|
||||
@@ -111,7 +127,8 @@ export type TrackCard = {
|
||||
};
|
||||
|
||||
export type OfficeArea = {
|
||||
owner: PlayerIndex;
|
||||
/** Where this Office sits in the chain, not who is sitting at it. See `SeatIndex`. */
|
||||
seat: SeatIndex;
|
||||
tier: OfficeTier;
|
||||
grid: Map<string, TrackCard>;
|
||||
officeCoord: GridCoord;
|
||||
@@ -223,7 +240,8 @@ export function isLockedByWork(card: TrackCard): boolean {
|
||||
export type NodeRef =
|
||||
| { at: 'divisionPoint'; side: Direction }
|
||||
| { at: 'mainline'; index: number }
|
||||
| { at: 'grid'; owner: PlayerIndex; coord: GridCoord };
|
||||
/** A tray standing in someone's district. `seat` is WHICH district, not whose turn it is. */
|
||||
| { at: 'grid'; seat: SeatIndex; coord: GridCoord };
|
||||
|
||||
export type CrewTray = {
|
||||
id: TrayId;
|
||||
@@ -328,7 +346,7 @@ export type DivisionNode =
|
||||
/** Red Flags protecting a stopped train here, by tray. */
|
||||
redFlagged?: TrayId[];
|
||||
}
|
||||
| { kind: 'office'; owner: PlayerIndex };
|
||||
| { kind: 'office'; seat: SeatIndex };
|
||||
|
||||
/** Ordered west to east. For N players: N Office nodes and N+1 Mainline cards. */
|
||||
export type Division = { nodes: DivisionNode[] };
|
||||
@@ -482,6 +500,30 @@ export type TurnState = {
|
||||
done: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* One turn per player, created together at phase entry.
|
||||
*
|
||||
* This was a single `TurnState` on the game, replaced one player at a time as the cursor walked the
|
||||
* table. That is indistinguishable from this while only the player at the cursor may act — which is
|
||||
* exactly the case today, and every existing test still describes the same game.
|
||||
*
|
||||
* It is per-player now because making it so later would mean the same change PLUS reworking a client
|
||||
* built around "wait your turn". What it enables is Local Operations work that no other player can
|
||||
* observe — switching inside your own district — happening off-cursor, which is a change to
|
||||
* `isActor` and nothing else. See `docs/architecture/multiplayer.md` D19.
|
||||
*/
|
||||
export function freshTurns(players: number, moves: number): Map<PlayerIndex, TurnState> {
|
||||
const turns = new Map<PlayerIndex, TurnState>();
|
||||
for (let p = 0; p < players; p++) turns.set(p, freshTurn(moves));
|
||||
return turns;
|
||||
}
|
||||
|
||||
export function turnOf(s: GameState, player: PlayerIndex): TurnState {
|
||||
const t = s.turns.get(player);
|
||||
if (!t) throw new Error(`no turn state for player ${player}`);
|
||||
return t;
|
||||
}
|
||||
|
||||
export function freshTurn(moves: number): TurnState {
|
||||
return {
|
||||
option: null,
|
||||
@@ -500,8 +542,15 @@ export type GameState = {
|
||||
seed: number;
|
||||
rngState: number;
|
||||
players: Player[];
|
||||
/**
|
||||
* Who is sitting where: `seating[seat] = player`.
|
||||
*
|
||||
* The identity mapping in every game today, which is what makes the seat/player split
|
||||
* behaviour-neutral. Employee Rotation would rotate this array and nothing else.
|
||||
*/
|
||||
seating: PlayerIndex[];
|
||||
division: Division;
|
||||
officeAreas: Map<PlayerIndex, OfficeArea>;
|
||||
officeAreas: Map<SeatIndex, OfficeArea>;
|
||||
trays: Map<TrayId, CrewTray>;
|
||||
/** Trays not yet in play; §7 scarcity is an explicit mechanic. */
|
||||
freeTrays: TrayId[];
|
||||
@@ -521,7 +570,8 @@ export type GameState = {
|
||||
*/
|
||||
pendingSecondSections: number[];
|
||||
clock: Clock;
|
||||
turn: TurnState;
|
||||
/** One per player, keyed by PLAYER (a turn belongs to a person, not to a chair). */
|
||||
turns: Map<PlayerIndex, TurnState>;
|
||||
/** Transient: trains already moved in the current Mainline Phase. Cleared when it ends. */
|
||||
movedThisPhase: Set<TrayId>;
|
||||
/** §3.4 — resets at the start of each Day. */
|
||||
@@ -551,7 +601,7 @@ export function subdivisions(state: GameState): number[][] {
|
||||
state.division.nodes.forEach((node, i) => {
|
||||
const isBoundary =
|
||||
node.kind === 'divisionPoint' ||
|
||||
(node.kind === 'office' && isControlPoint(state, node.owner));
|
||||
(node.kind === 'office' && isControlPoint(state, node.seat));
|
||||
|
||||
if (isBoundary) {
|
||||
if (current.length > 0) out.push(current);
|
||||
@@ -565,15 +615,29 @@ export function subdivisions(state: GameState): number[][] {
|
||||
return out;
|
||||
}
|
||||
|
||||
export function isControlPoint(state: GameState, owner: PlayerIndex): boolean {
|
||||
const area = state.officeAreas.get(owner);
|
||||
if (!area) throw new Error(`no Office Area for player ${owner}`);
|
||||
/** Who is sitting at this seat. */
|
||||
export function playerAtSeat(state: GameState, seat: SeatIndex): PlayerIndex {
|
||||
const p = state.seating[seat];
|
||||
if (p === undefined) throw new Error(`no player at seat ${seat}`);
|
||||
return p;
|
||||
}
|
||||
|
||||
/** Where this player is sitting, and therefore which Office Area is theirs. */
|
||||
export function seatOf(state: GameState, player: PlayerIndex): SeatIndex {
|
||||
const seat = state.seating.indexOf(player);
|
||||
if (seat < 0) throw new Error(`player ${player} is not seated`);
|
||||
return seat;
|
||||
}
|
||||
|
||||
export function isControlPoint(state: GameState, seat: SeatIndex): boolean {
|
||||
const area = state.officeAreas.get(seat);
|
||||
if (!area) throw new Error(`no Office Area at seat ${seat}`);
|
||||
return officeProfile(area.tier).isControlPoint;
|
||||
}
|
||||
|
||||
export function adTrackCount(state: GameState, owner: PlayerIndex): number {
|
||||
const area = state.officeAreas.get(owner);
|
||||
if (!area) throw new Error(`no Office Area for player ${owner}`);
|
||||
export function adTrackCount(state: GameState, seat: SeatIndex): number {
|
||||
const area = state.officeAreas.get(seat);
|
||||
if (!area) throw new Error(`no Office Area at seat ${seat}`);
|
||||
return officeProfile(area.tier).adTracks;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user