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
+48 -31
View File
@@ -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
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':
+3 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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;
}
+4 -3
View File
@@ -84,7 +84,8 @@ export function divisionSvg(nodes: DivisionView[]): string {
}[];
cap: number | null;
tip: string;
owner: number | null;
/** Which SEAT's district this cell belongs to, or null for Mainline and Division Points. */
seat: number | null;
/** Mainline cards only: §2.1 divides one into two regions. 0 elsewhere — no bars are drawn. */
regions: number;
w: number;
@@ -120,7 +121,7 @@ export function divisionSvg(nodes: DivisionView[]): string {
: rc.trains,
cap: isOffice ? cap : null,
tip: `${rc.label} — ${rc.kind === 'limits' ? 'the end of this district; the Running Track runs between the Limits' : 'Running Track'}`,
owner: n.owner ?? null,
seat: n.seat ?? null,
// No regions inside a district: a crew moves by Moves there, not by Stages, so it
// occupies a card outright rather than a part of one.
regions: 0,
@@ -148,7 +149,7 @@ export function divisionSvg(nodes: DivisionView[]): string {
tip: dp
? 'A Division Point — the end of the line. Trains both enter and leave the Division here (odd numbers run west, even run east), and queue without limit'
: `${n.label} — Mainline${n.gradeUp ? `, climbs ${n.gradeUp === 'east' ? 'east' : 'west'}` : ''}${n.modifiers.length ? ` · ${n.modifiers.join(' · ')}` : ''}`,
owner: null,
seat: null,
// A Division Point is one region — the queue trains enter and leave the Division through.
regions: dp ? 1 : (n.regions ?? 0),
w: dp ? CW.dp : CW.ml,
+18 -20
View File
@@ -27,7 +27,7 @@ import type { Intent } from '../engine/intents.ts';
import { legalActions } from '../engine/legal.ts';
import { connectionsFor, exitsFrom, facilityVariants, hasPort, joins, neighbour, opposite, variantsFor } from '../engine/track.ts';
import type { Port } from '../engine/track.ts';
import { coordKey } from '../engine/state.ts';
import { coordKey, turnOf } from '../engine/state.ts';
import type { Facility, GameState, GridCoord, OfficeArea, PlayerIndex, RollingStock, TrackCard } from '../engine/state.ts';
export type BotPolicy = {
@@ -206,7 +206,7 @@ export function makeDeveloperBot(tweaks: BotTweaks): BotPolicy {
? options.filter((i) => !(i.type === 'card.play' && isTrainCard(s, i.cardId)))
: options;
const usable = held.length > 0 ? held : options;
if (s.turn.option === null) return chooseLocalOption(s, player, usable, tweaks);
if (turnOf(s, player).option === null) return chooseLocalOption(s, player, usable, tweaks);
return followThrough(s, player, usable, tweaks);
}
@@ -339,7 +339,7 @@ function chooseLocalOption(
* NO BRANCH FOR MAINLINE MODIFIERS, and that is the measured answer rather than an oversight.
*
* There was one. It read `options.some((i) => i.type === 'mainline.modify')`, but `mainline.modify`
* requires `s.turn.option === 'draw'` and this runs while the option is still null, so
* requires `turnOf(s, player).option === 'draw'` and this runs while the option is still null, so
* `legalActions` had already filtered it out — the branch could never fire and never had.
*
* Rewriting it to check the HAND made it work, and made the bot WORSE: -0.64 revenue a game,
@@ -402,7 +402,7 @@ function topOfDepartment(s: GameState, slot: number): string | undefined {
* In a competitive game the same call reads the other way round — burying a card a rival wants is an
* attack — which is why the choice belongs to the discarding player and not to the rules.
*/
function bestDiscard(s: GameState, options: Intent[]): Intent | null {
function bestDiscard(s: GameState, player: PlayerIndex, options: Intent[]): Intent | null {
let best: Intent | null = null;
let bestScore = -Infinity;
for (const i of options) {
@@ -412,7 +412,7 @@ function bestDiscard(s: GameState, options: Intent[]): Intent | null {
// two showing whatever they happened to start with. Measured over 100 games — spreading 2.87
// revenue, concentrating on the deepest 2.67, indifferent 2.67.
const top = topOfDepartment(s, i.toSlot);
const wanted = isWorthTaking(s, i.toSlot);
const wanted = isWorthTaking(s, player, i.toSlot);
const depth = s.decks.departments[i.toSlot]?.length ?? 0;
const score = (top === undefined ? 6 : wanted ? -10 : 2) - Math.min(depth, 4) * 0.5;
if (score > bestScore) {
@@ -424,8 +424,8 @@ function bestDiscard(s: GameState, options: Intent[]): Intent | null {
}
/** A face-up card worth spending the draw on rather than gambling on the deck. */
function isWorthTaking(s: GameState, slot: number): boolean {
return takingRank(s, slot) > 0;
function isWorthTaking(s: GameState, player: PlayerIndex, slot: number): boolean {
return takingRank(s, player, slot) > 0;
}
/**
@@ -435,12 +435,12 @@ function isWorthTaking(s: GameState, slot: number): boolean {
* happened to be scanned first — a coin flip on the card that decides whether the district ever
* becomes a Passenger Facility at all.
*/
function takingRank(s: GameState, slot: number): number {
function takingRank(s: GameState, player: PlayerIndex, slot: number): number {
const id = topOfDepartment(s, slot);
if (!id) return 0;
const k = s.cards.get(id)?.kind;
if (!k) return 0;
if (k.kind === 'office') return nextOfficeTier(areaOf(s, 0).tier) === k.tier ? 3 : 0;
if (k.kind === 'office') return nextOfficeTier(areaOf(s, player).tier) === k.tier ? 3 : 0;
if (k.kind === 'timetabledTrain' || k.kind === 'extraTrain') return 2;
if (k.kind === 'freightFacility') return 1;
return 0;
@@ -646,8 +646,7 @@ function runAroundCells(area: OfficeArea): Set<string> {
* there is one the crew dare not serve.
*/
function bestFacilityPlay(s: GameState, player: PlayerIndex, options: Intent[]): Intent | null {
const area = s.officeAreas.get(player);
if (!area) return null;
const area = areaOf(s, player);
const onLoop = runAroundCells(area);
const reachable = reachableOffMain(area);
@@ -703,8 +702,7 @@ function bestFacilityPlay(s: GameState, player: PlayerIndex, options: Intent[]):
}
function bestTrackLay(s: GameState, player: PlayerIndex, options: Intent[]): Intent | null {
const area = s.officeAreas.get(player);
if (!area) return null;
const area = areaOf(s, player);
const at = (row: number, col: number): TrackCard | undefined => area.grid.get(`${row},${col}`);
@@ -1125,17 +1123,17 @@ function followThrough(
options: Intent[],
tweaks: BotTweaks,
): Intent {
switch (s.turn.option) {
switch (turnOf(s, player).option) {
case 'draw': {
// Draw before playing — otherwise the hand empties and never refills.
//
// Prefer a face-up Department card only when it is actually worth having. Taking the visible
// card unconditionally meant the bot never once drew blind from the deck across 60 games,
// which the anomaly detector correctly flagged: a whole branch of §6.2 going unexercised.
if (!s.turn.drawnThisTurn) {
if (!turnOf(s, player).drawnThisTurn) {
const piles = options.filter(
(i): i is Extract<Intent, { type: 'draw.fromDepartment' }> =>
i.type === 'draw.fromDepartment' && isWorthTaking(s, i.slot),
i.type === 'draw.fromDepartment' && isWorthTaking(s, player, i.slot),
);
// Best-ranked pile rather than the first that qualifies: an Office card and a train card
// both "qualify", and only one of them stops the collisions.
@@ -1143,7 +1141,7 @@ function followThrough(
// and a train card are face up together 1.6 decisions a game — but ranking them is what the
// ranking function is for, and a coin flip on the card that decides whether the district
// ever becomes a Passenger Facility is not worth keeping for its own sake.
const useful = piles.sort((a, b) => takingRank(s, b.slot) - takingRank(s, a.slot))[0];
const useful = piles.sort((a, b) => takingRank(s, player, b.slot) - takingRank(s, player, a.slot))[0];
if (useful) return because('a face-up card is worth more than a blind draw right now', useful);
const blind = options.find((i) => i.type === 'draw.fromHomeOffice');
if (blind) return because('no face-up card is worth taking — gamble on the deck', blind);
@@ -1235,7 +1233,7 @@ function followThrough(
if (end) return because('nothing in hand can be played anywhere legal', end);
return because(
'nothing playable — discard onto the Department whose face-up card is least worth keeping reachable',
bestDiscard(s, options) ?? pickFirst(options, 'card.discard') ?? options[0]!,
bestDiscard(s, player, options) ?? pickFirst(options, 'card.discard') ?? options[0]!,
);
}
@@ -1654,14 +1652,14 @@ function strandedWantedCars(s: GameState, player: PlayerIndex): { row: number; c
function trayOf(s: GameState, player: PlayerIndex) {
for (const tray of s.trays.values()) {
if (tray.position.at === 'grid' && tray.position.owner === player) return tray;
if (tray.position.at === 'grid' && tray.position.seat === player) return tray;
}
return null;
}
function trayLocation(s: GameState, player: PlayerIndex): { row: number; col: number } | null {
for (const tray of s.trays.values()) {
if (tray.position.at === 'grid' && tray.position.owner === player) {
if (tray.position.at === 'grid' && tray.position.seat === player) {
return tray.position.coord;
}
}
+9 -9
View File
@@ -15,9 +15,9 @@
* panel cannot drift from the rules.
*/
import { adTrackCount, coordKey } from '../engine/state.ts';
import type { GameState, GridCoord, RollingStock, TrayId } from '../engine/state.ts';
import { canAdvanceLoad, canStartLoad, facilityCarType, laborersLeft, movesFor, portersLeft } from '../engine/apply.ts';
import { adTrackCount, coordKey, seatOf, turnOf } from '../engine/state.ts';
import type { GameState, GridCoord, PlayerIndex, RollingStock, TrayId } from '../engine/state.ts';
import { areaOf, canAdvanceLoad, canStartLoad, facilityCarType, laborersLeft, movesFor, portersLeft } from '../engine/apply.ts';
import type { GameEvent } from '../engine/events.ts';
// ---------------------------------------------------------------------------
@@ -446,10 +446,9 @@ export type Impediment = { where: string; why: string; severity: 'stuck' | 'wait
* This is the panel that should answer the standing questions: whether facilities jam, whether
* trains are held for want of a crew, whether the Office is about to cause a collision.
*/
export function impediments(s: GameState, player = 0): Impediment[] {
export function impediments(s: GameState, player: PlayerIndex = 0): Impediment[] {
const out: Impediment[] = [];
const area = s.officeAreas.get(player);
if (!area) return out;
const area = areaOf(s, player);
for (const [key, card] of area.grid) {
const f = card.facility;
@@ -512,9 +511,10 @@ export function impediments(s: GameState, player = 0): Impediment[] {
* Only while switching, and only the cards actually in the crew's way: `movesFor` reports the
* squares the movement walk reached and refused, not every square on the board.
*/
if (s.clock.phase === 'localOps' && s.turn.option === 'switch' && s.turn.movesRemaining > 0) {
const turn = turnOf(s, player);
if (s.clock.phase === 'localOps' && turn.option === 'switch' && turn.movesRemaining > 0) {
for (const [id, 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 { blocked } = movesFor(s, player, id);
for (const b of blocked) {
// A turnout is not an obstruction — a train runs through one all day and simply may not
@@ -583,7 +583,7 @@ export function impediments(s: GameState, player = 0): Impediment[] {
}
// A full Office means the next arrival is an automatic collision (Gap 2d).
const cap = adTrackCount(s, player);
const cap = adTrackCount(s, seatOf(s, player));
if (area.adOccupancy.length >= cap) {
out.push({
where: 'Office',
+2 -1
View File
@@ -25,6 +25,7 @@ import { writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { areaAtSeat } from '../engine/apply.ts';
import { legalActions } from '../engine/legal.ts';
import type { BotPolicy } from './bot.ts';
import { developerBot, makeDeveloperBot } from './bot.ts';
@@ -71,7 +72,7 @@ export function playForReplay(seed: number, policy: BotPolicy, maxTurns = 50_000
save: toSave(game),
note:
`${revenue} Revenue over ${game.state.clock.day - 1} Days · ${trains} train(s) on the timetable · ` +
`${game.state.officeAreas.get(0)?.grid.size ?? 0} cards down` +
`${areaAtSeat(game.state, 0).grid.size} cards down` +
(collisions > 0 ? ` · ${collisions} collision(s)` : ' · no collisions'),
};
}
+6 -3
View File
@@ -18,7 +18,7 @@
import type { GameEvent } from '../engine/events.ts';
import type { Intent } from '../engine/intents.ts';
import type { GameState } from '../engine/state.ts';
import { areaOf, facilityCarTypes } from '../engine/apply.ts';
import { areaAtSeat, areaOf, facilityCarTypes } from '../engine/apply.ts';
import { officeProfile } from '../engine/content.ts';
// ---------------------------------------------------------------------------
@@ -121,7 +121,7 @@ export function makeFunnelProbe(player = 0): {
// A buried engine is a per-decision condition: every turn it persists is a turn the train is
// stuck, so this counts turns rather than trains.
for (const tray of s.trays.values()) {
if (tray.position.at !== 'grid' || tray.position.owner !== player) continue;
if (tray.position.at !== 'grid' || tray.position.seat !== player) continue;
if (tray.engineAt <= 0 || tray.engineAt >= tray.consist.length) continue;
funnel.buriedTurns += 1;
// Cars may never be set out at the Office (§A.4), which is where this almost always happens.
@@ -295,7 +295,10 @@ export function summarize(
const grossPassenger = rev.passengerBoard + rev.passengerDetrain;
const gross = grossFreight + grossPassenger;
const area = final.officeAreas.get(0);
// Seat 0 explicitly: these are SOLITAIRE summaries, where there is exactly one Office Area and
// seat 0 is the only player. `areaAtSeat` rather than `officeAreas.get` so the key's meaning is
// stated — a multi-player report would have to sum over seats, and would fail loudly here first.
const area = areaAtSeat(final, 0);
let freightFacilities = 0;
if (area) {
for (const card of area.grid.values()) {
+57 -20
View File
@@ -11,6 +11,7 @@
*/
import {
areaAtSeat,
areaOf,
destinationsFor,
facilityCarType,
@@ -40,6 +41,7 @@ import {
} from '../engine/content.ts';
import type { Intent } from '../engine/intents.ts';
import type { Facility, GameState, PlayerIndex, TrackCard, TurnoutOrientation } from '../engine/state.ts';
import { playerAtSeat, seatOf, turnOf } from '../engine/state.ts';
import type { Hand, TrackGeometry } from '../engine/content.ts';
import type { Port } from '../engine/track.ts';
import { connectionsFor, slopeOfPair, variantsFor } from '../engine/track.ts';
@@ -250,7 +252,8 @@ export type DivisionView = {
/** Office nodes only: the Running Track, Limits to Limits, west to east. */
running?: RunningCardView[];
/** Office nodes only: whose district this is. */
owner?: number;
/** Which SEAT's district this is — a position on the Division, not a player. */
seat?: number;
/**
* Office nodes only: crews working BELOW the Running Track.
*
@@ -275,6 +278,21 @@ export type Frame = {
actor: number | null;
superintendent: number;
revenue: number;
/**
* The rest of what a client needs so it never has to reach into `GameState`.
*
* The browser client used to read `game.state` in eleven places for exactly these. That is fine
* with the engine in the same process and impossible with a server, where the client holds no
* state at all — so they live on the projection instead. See `docs/architecture/multiplayer.md` §5.
*/
/** Which of §6's three exclusive options the VIEWER has taken this Stage, if any. */
option: 'switch' | 'draw' | 'freightAgent' | null;
status: GameState['status'];
outcome: GameState['outcome'];
/** Every seat's public standing — names and Revenue. "The race is the game" (protocol.md §4). */
players: { index: number; name: string; revenue: number; hand: number }[];
/** How many cards the VIEWER holds. Other players' counts are in `players`. */
handCount: number;
lines: { text: string; tone: string }[];
where: { row: number; col: number } | null;
/** Origin of a Move, so the crew's journey is visible rather than a chip teleporting. */
@@ -548,7 +566,10 @@ function sampleDetail(s: GameState, kind: string, list: Intent[]): string {
export function describeIntent(s: GameState, i: Intent): string {
const at = (c: { row: number; col: number }): string => `(${c.row},${c.col})`;
// An intent belongs to whoever is acting, so it is described against THEIR district.
const seat: PlayerIndex = s.clock.currentActor ?? 0;
// The acting PLAYER, not a seat — `describeIntent` describes an intent against the district of
// whoever is making it. Named `seat` once, and then used as an `officeAreas` key, which is the
// exact confusion the seat/player split exists to stop.
const actor: PlayerIndex = s.clock.currentActor ?? 0;
switch (i.type) {
case 'localOps.choose':
// The most consequential decision of the Stage, and it was labelled "choose switch". Say what
@@ -578,7 +599,7 @@ export function describeIntent(s: GameState, i: Intent): string {
* square was not empty. The Limits sign is excluded: laying track there is ordinary growth.
*/
const over = i.placement
? s.officeAreas.get(seat)?.grid.get(`${i.placement.row},${i.placement.col}`)
? areaOf(s, actor).grid.get(`${i.placement.row},${i.placement.col}`)
: undefined;
const upgrade = over?.geometry.kind === 'track';
return (
@@ -616,7 +637,7 @@ export function describeIntent(s: GameState, i: Intent): string {
const here = tray?.position.at === 'grid' ? tray.position.coord : null;
let picks = '';
if (here) {
const dest = destinationsFor(s, tray!.position.at === 'grid' ? tray!.position.owner : 0, i.trayId, here, i.reverse)
const dest = destinationsFor(s, tray!.position.at === 'grid' ? playerAtSeat(s, tray!.position.seat) : 0, i.trayId, here, i.reverse)
.find((d) => d.coord.row === i.to.row && d.coord.col === i.to.col);
if (dest && dest.couples.length > 0) {
picks = ` — couples ${carsLabel(dest.couples)} on the way${i.reverse ? ' (behind)' : ' (onto the nose)'}`;
@@ -651,7 +672,7 @@ export function describeIntent(s: GameState, i: Intent): string {
* waiting for a train that can carry it. For a passenger facility that load is passengers on
* the platform.
*/
const f = areaOf(s, seat).grid.get(`${i.at.row},${i.at.col}`)?.facility ?? null;
const f = areaOf(s, actor).grid.get(`${i.at.row},${i.at.col}`)?.facility ?? null;
const where = f?.kind === 'passenger' ? 'onto the platform' : 'into the green Loading box';
return i.carType === 'coach' && f?.kind === 'passenger'
? `bring passengers ${where} at ${at(i.at)} — they wait there for a train with an empty coach`
@@ -786,9 +807,14 @@ export function snapshot(
viewer: PlayerIndex = 0,
): Frame {
const area = areaOf(s, viewer);
const viewerSeat = seatOf(s, viewer);
const trayAt = new Map<string, string>();
for (const [id, tray] of s.trays) {
if (tray.position.at === 'grid') {
// KEYED BY COORDINATE, so it must be filtered by seat first. Every district uses the same
// (row, col) origin, so without this a crew standing at (0,1) in one player's Office Area is
// drawn onto (0,1) of every other player's board — the cells come from `area.grid`, which is
// the viewer's, but the train on them came from anybody's.
if (tray.position.at === 'grid' && tray.position.seat === viewerSeat) {
const label = tray.trainNumber === null ? 'crew' : `T${tray.trainIsExtra ? 'X' : ''}${tray.trainNumber}`;
const carrying = tray.consist.length ? ` [${tray.consist.map(carLabel).join(', ')}]` : ' [empty]';
trayAt.set(`${tray.position.coord.row},${tray.position.coord.col}`, label + carrying);
@@ -906,14 +932,14 @@ export function snapshot(
gradeUp: isGrade ? (n.gradeUp ?? 'east') : null,
};
}
const oa = areaOf(s, n.owner);
const oa = areaAtSeat(s, n.seat);
// Where every crew in this district actually is: on a Running Track card, or below it.
const onRunning = new Map<string, TrainChip[]>();
const below: TrainChip[] = [];
for (const [id, tray] of s.trays) {
const pos = tray.position;
if (pos.at !== 'grid' || pos.owner !== n.owner) continue;
if (pos.at !== 'grid' || pos.seat !== n.seat) continue;
const c = trainChip(s, id);
if (pos.coord.row === oa.runningRow) {
const k = `${pos.coord.row},${pos.coord.col}`;
@@ -954,7 +980,7 @@ export function snapshot(
capacity: officeProfile(oa.tier).adTracks,
modifiers: [],
gradeUp: null,
owner: n.owner,
seat: n.seat,
running,
switching: below,
};
@@ -968,7 +994,7 @@ export function snapshot(
phaseKey: s.clock.phase,
actor: s.clock.currentActor,
superintendent: s.clock.superintendent,
revenue: s.players[0]?.revenue ?? 0,
revenue: s.players[viewer]?.revenue ?? 0,
lines,
where,
whereFrom,
@@ -1011,11 +1037,21 @@ export function snapshot(
timetable: [...s.timetable],
decision,
wasted,
objective: objectiveOf(s),
option: turnOf(s, viewer).option,
status: s.status,
outcome: s.outcome,
players: s.players.map((p) => ({
index: p.index,
name: p.name,
revenue: p.revenue,
hand: (s.decks.hands.get(p.index) ?? []).length,
})),
handCount: (s.decks.hands.get(viewer) ?? []).length,
objective: objectiveOf(s, viewer),
runningRow: area.runningRow,
movesLeft: s.clock.phase === 'localOps' && s.turn.option === 'switch' ? s.turn.movesRemaining : null,
moves: switchingMoves(s, 0),
blocked: impediments(s, 0),
movesLeft: s.clock.phase === 'localOps' && turnOf(s, viewer).option === 'switch' ? turnOf(s, viewer).movesRemaining : null,
moves: switchingMoves(s, viewer),
blocked: impediments(s, viewer),
trains: [...s.trays.values()].map((t) => ({
label: t.trainNumber === null ? 'local crew' : `Train ${t.trainIsExtra ? 'X' : ''}${t.trainNumber}`,
where:
@@ -1410,10 +1446,10 @@ const SIMPLE_CARDS = [
...ACTION_CARDS,
];
/** The goal, and whether the current score is keeping up with the clock. */
function objectiveOf(s: GameState): Frame['objective'] {
/** The goal, and whether the VIEWER's score is keeping up with the clock. */
function objectiveOf(s: GameState, viewer: PlayerIndex): Frame['objective'] {
const profile = lengthProfile(s.config.length);
const revenue = s.players[0]?.revenue ?? 0;
const revenue = s.players[viewer]?.revenue ?? 0;
const daysLeft = Math.max(0, profile.days - s.clock.day + 1);
const elapsed = profile.days - daysLeft + 1;
// Straight-line pace: by the end of Day N you want N/days of the target.
@@ -1505,10 +1541,11 @@ function countStock(
* question the page does not yet ask.
*/
function switchingMoves(s: GameState, player: PlayerIndex): Frame['moves'] {
if (s.clock.phase !== 'localOps' || s.turn.option !== 'switch') return null;
if (s.turn.movesRemaining < 1) return null;
const turn = turnOf(s, player);
if (s.clock.phase !== 'localOps' || turn.option !== 'switch') return null;
if (turn.movesRemaining < 1) return null;
for (const [id, 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 { to, blocked } = movesFor(s, player, id);
return { from: tray.position.coord, to, blocked };
}
+12 -3
View File
@@ -64,7 +64,14 @@ export const SOLO_CONFIG: GameConfig = {
export type ActionGroup = {
kind: string;
title: string;
actions: { index: number; label: string }[];
/**
* `tip` is the card's own description, resolved HERE rather than by the page.
*
* The button label is short; the hover text used to be looked up with `cardDescription(state, id)`
* from the browser, which needs the whole `GameState`. A remote client has no state, so the menu
* carries it. See `docs/architecture/multiplayer.md` §5.
*/
actions: { index: number; label: string; tip?: string }[];
};
export type Game = {
@@ -175,13 +182,15 @@ export function actionGroups(game: Game): { options: Intent[]; groups: ActionGro
if (actor === null) return { options: [], groups: [] };
const options = legalActions(game.state, actor);
const byKind = new Map<string, { index: number; label: string }[]>();
const byKind = new Map<string, { index: number; label: string; tip?: string }[]>();
options.forEach((intent, index) => {
const label = describeIntent(game.state, intent);
const list = byKind.get(intent.type) ?? [];
const cardId = 'cardId' in intent ? (intent as { cardId: string }).cardId : null;
const tip = cardId ? cardDescription(game.state, cardId) : undefined;
// Orientation variants and duplicate copies describe identically; showing one is enough, and a
// list of forty identical rows hides the real choice rather than presenting it.
if (!list.some((a) => a.label === label)) list.push({ index, label });
if (!list.some((a) => a.label === label)) list.push({ index, label, ...(tip ? { tip } : {}) });
byKind.set(intent.type, list);
});
+88 -77
View File
@@ -1,5 +1,5 @@
/**
* Browser entry point — wires the DOM to `game.ts`.
* Browser entry point — wires the DOM to a `Session`.
*
* Presentation only. Every question of what is legal, what it means, or what the board looks like
* is answered by the engine or by the shared view helpers.
@@ -7,26 +7,25 @@
import { BOARD_CSS, divisionSvg, officeSvg } from '../sim/board-svg.ts';
import { TURNCHART_CSS, turnChartHtml } from '../sim/turnchart.ts';
import type { Frame } from '../sim/view.ts';
import type { Menu, Save } from './game.ts';
import { PANEL_CSS, blockedHtml, facilitiesHtml, pilesHtml, timetableHtml, yardHtml } from './panels.ts';
import { TOOLTIP_CSS, installTooltips } from './tooltip.ts';
import { playCue } from './sound.ts';
import type { Game } from './game.ts';
import { MOVES_PER_LOCAL_OPS } from '../engine/content.ts';
import { cardDescription } from '../sim/view.ts';
import {
actionMenu,
currentActor,
fromSave,
newGame,
submit,
toSave,
undo,
view,
} from './game.ts';
import type { LocalSession } from './session.ts';
import { createLocalSession } from './session.ts';
const SAVE_KEY = 'station-master.save.v1';
let game: Game;
/**
* The game, behind the Session boundary.
*
* Typed as `LocalSession` because this page is the solitaire client and uses undo, local saves and
* new-game — all of which are local-only. The parts that draw and submit go through the plain
* `Session` surface, which is what a remote client will provide unchanged.
*/
let session: LocalSession;
/** Which card or track piece is picked, waiting for a location. */
let selected: string | null = null;
/**
@@ -108,8 +107,8 @@ function piecePreview(links: string[], label: string): string {
* Built by `turnChartHtml`, shared with both replay viewers, so all three screens report where you
* are in the Day the same way and with the same violet highlight. It used to live here alone.
*/
function renderTurnChart(f: ReturnType<typeof view>): void {
const actorName = f.actor === null ? null : (game.state.players[f.actor]?.name ?? null);
function renderTurnChart(f: Frame): void {
const actorName = f.actor === null ? null : (f.players[f.actor]?.name ?? null);
$('turnchart').innerHTML = turnChartHtml(f, actorName);
}
@@ -117,23 +116,42 @@ function start(): void {
const params = new URLSearchParams(location.search);
const requested = params.get('seed');
// A seed in the URL makes a game shareable and reproducible: same link, same deal.
const seed = requested !== null ? Number(requested) || 1 : Math.floor(Math.random() * 1e9);
session = createLocalSession(seed);
const saved = load();
if (saved && requested === null) {
game = fromSave(saved);
// Restoring replays the whole history, which re-records every draw along the way. Nothing on
// this screen is news to the player who left it there, so the "new card" badge starts clear.
game.justDrawn = null;
} else {
// A seed in the URL makes a game shareable and reproducible: same link, same deal.
const seed = requested !== null ? Number(requested) || 1 : Math.floor(Math.random() * 1e9);
game = newGame(seed);
}
if (saved && requested === null) session.restore(saved);
applyCapabilities();
// Every render goes through the session, so the page redraws whenever the game says it changed —
// which is what a remote session will use to push. Locally it fires on each accepted intent.
session.subscribe(render);
render();
}
/**
* Hide the controls this session does not offer.
*
* Undo, a local save and dealing a new game are all things only a local session can do — a server
* cannot un-see what the other players have already seen, the server is the store, and dealing is
* the lobby's job. Hidden rather than disabled: a greyed-out Undo in a multiplayer game invites the
* question "why not?" every turn, and the honest answer is that the control does not belong there.
*/
function applyCapabilities(): void {
const c = session.capabilities;
const hide = (id: string, on: boolean): void => {
const el = document.getElementById(id);
if (el) el.hidden = !on;
};
hide('undo', c.undo);
hide('savefile', c.saveLocal);
hide('newgame', c.newGame);
}
function render(): void {
const f = view(game);
const menu = actionMenu(game);
const f = session.view();
const menu = session.menu();
// Which squares the selected card or track piece may go on. Highlighting them is what turns the
// coordinate list into a board: you pick the thing, then click where it goes.
@@ -166,7 +184,7 @@ function render(): void {
const obj = $('objective');
obj.textContent = `${f.revenue} of ${f.objective.target} · ${f.objective.daysLeft} Day${f.objective.daysLeft === 1 ? '' : 's'} left`;
obj.className = 'pace';
$('seed').textContent = String(game.seed);
$('seed').textContent = String(session.seed());
// -- division
$('division').innerHTML = divisionSvg(f.division);
@@ -255,7 +273,7 @@ function render(): void {
function pick(key: string, list: { label: string; index: number }[]): void {
if (list.length === 1) {
const intent = menu.options[list[0]!.index];
if (intent) submit(game, intent);
if (intent) void session.submit(intent);
selected = null;
pendingAt = null;
} else {
@@ -296,7 +314,7 @@ function render(): void {
// The card just drawn, badged so it can be told from the two beside it. It stands until
// another draw replaces it, rather than flashing once — the question a player asks looking
// at the row is "which of these is new", not "did something happen".
const fresh = h.cardId === game.justDrawn;
const fresh = h.cardId === session.justDrawn();
return (
`<div class="handcard${canPlay ? '' : ' unplayable'}${picked ? ' picked' : ''}${fresh ? ' fresh' : ''}"${fig}` +
`${h.what ? ` data-tip="${esc(h.what)}"` : ''} tabindex="0">` +
@@ -341,7 +359,7 @@ function render(): void {
// A card that needs no square goes straight down; there is nothing to ask.
if (verb === 'play' && entry.placeKey === null && entry.playNow !== null) {
const intent = menu.options[entry.playNow];
if (intent) submit(game, intent);
if (intent) void session.submit(intent);
selected = null;
mode = null;
pendingAt = null;
@@ -374,7 +392,7 @@ function render(): void {
node.classList.add('target');
node.onclick = () => {
const intent = menu.options[index];
if (intent) submit(game, intent);
if (intent) void session.submit(intent);
selected = null;
mode = null;
render();
@@ -397,7 +415,7 @@ function render(): void {
node.classList.add('addable');
node.onclick = () => {
const intent = menu.options[car.index];
if (intent) submit(game, intent);
if (intent) void session.submit(intent);
render();
};
}
@@ -409,15 +427,16 @@ function render(): void {
* `scheduled` is cleared as it is consumed, so the flash marks the moment rather than the state —
* it is gone by the next render, which is what makes it read as "that just happened".
*/
const justSet = game.scheduled;
game.scheduled = null;
// Draining: the flash marks the moment the die was read, not a state, so it is gone by the next
// render. Taken once here and handed to both the timetable and the action panel.
const justSet = session.takeScheduled();
$('timetable').innerHTML = timetableHtml(f, justSet);
$('blocked').innerHTML = blockedHtml(f);
// -- log
const log = $('log');
log.innerHTML = game.log
log.innerHTML = session.lines()
.slice(-60)
.map((l) => `<div class="line t-${l.tone}">${esc(l.text)}</div>`)
.join('');
@@ -446,7 +465,7 @@ function render(): void {
// Drain whatever the last batch of events earned. Cleared either way, so turning sound on does
// not then play a backlog of everything that happened while it was off.
const cues = game.cues.splice(0, game.cues.length);
const cues = session.takeCues();
if (soundOn) for (const c of cues) playCue(c);
save();
@@ -465,27 +484,20 @@ function render(): void {
*/
function renderUndo(): void {
const btn = document.getElementById('undo') as HTMLButtonElement | null;
if (!btn) return;
const n = game.history.length;
if (!btn || !session.capabilities.undo) return;
const n = session.steps();
btn.disabled = n === 0;
btn.textContent = n === 0 ? 'Undo' : `Undo (${n})`;
btn.onclick = () => {
const back = undo(game);
if (!back) return;
game = back;
// The session drops the rebuilt game's cues and draws — replaying the history re-records them,
// and none of it is news to a player who just stepped back.
if (!session.undo()) return;
selected = null;
mode = null;
pendingAt = null;
// The rebuilt game replays its own cues from the beginning; none of them are news. `justDrawn`
// goes with them: replaying the history re-records every draw, so it would badge whichever card
// the replay happened to end on rather than one the player just turned over.
game.cues.length = 0;
game.scheduled = null;
game.justDrawn = null;
// A phase change is announced by comparing against the last frame drawn. Stepping BACK into a
// different phase is not that event, so the banner is suppressed rather than fired backwards.
lastPhase = null;
render();
};
}
@@ -496,7 +508,7 @@ function renderUndo(): void {
* Division Yard is BARE. So the interesting number is not how much has been used but how little is
* left, and the moment the Division Yard empties a whole pile comes back at once.
*/
function renderYards(f: ReturnType<typeof view>): void {
function renderYards(f: Frame): void {
$('divyard').innerHTML = yardHtml(f.yards.division);
$('clsyard').innerHTML = yardHtml(f.yards.classification);
$('divtot').textContent = `${f.yards.divisionTotal} cars`;
@@ -510,7 +522,7 @@ function renderYards(f: ReturnType<typeof view>): void {
: 'loaded / empty. Classification returns to the Division Yard only when the Division Yard is bare.';
}
function renderDistrict(f: ReturnType<typeof view>): void {
function renderDistrict(f: Frame): void {
const open = districtMode === 'auto' ? FOCUS_PHASES.has(f.phaseKey) : districtMode === 'open';
const sec = $('district');
if (open) sec.classList.remove('folded');
@@ -539,18 +551,18 @@ function renderDistrict(f: ReturnType<typeof view>): void {
}
function renderActions(
menu: ReturnType<typeof actionMenu>,
f: ReturnType<typeof view>,
menu: Menu,
f: Frame,
justSet: number | null,
): void {
const el = $('actions');
if (game.state.status !== 'active') {
const o = game.state.outcome;
if (f.status !== 'active') {
const o = f.outcome;
el.innerHTML =
`<div class="over ${o?.result === 'win' ? 'win' : 'loss'}">` +
`${o?.result === 'win' ? 'YOU WIN' : 'GAME OVER'} — ${esc(String(o?.reason ?? ''))}<br>` +
`final Revenue ${game.state.players[0]?.revenue ?? 0} against a target of ${view(game).objective.target}</div>` +
`final Revenue ${f.revenue} against a target of ${f.objective.target}</div>` +
`<button id="again">new game</button>`;
$('again').onclick = () => {
clearSave();
@@ -566,7 +578,7 @@ function renderActions(
const apply = (index: number): void => {
const intent = menu.options[index];
if (intent) submit(game, intent);
if (intent) void session.submit(intent);
selected = null;
mode = null;
pendingAt = null;
@@ -582,15 +594,12 @@ function renderActions(
* the same card in hand explained itself perfectly — reported on "Realignment on Mainline card 3",
* which had neither a name for the card it meant nor a word about what it would do.
*/
const actionButton = (label: string, index: number): string => {
const actionButton = (a: { index: number; label: string; tip?: string }): string => {
const { label, index } = a;
const cut = label.indexOf(' — ');
const head = cut > 0 ? label.slice(0, cut) : label;
let rest = cut > 0 ? label.slice(cut + 3) : '';
if (!rest) {
const intent = menu.options[index];
const cardId = intent && 'cardId' in intent ? (intent as { cardId: string }).cardId : null;
if (cardId) rest = cardDescription(game.state, cardId);
}
// The menu resolved the card's description server-side, so the page never needs the state.
const rest = cut > 0 ? label.slice(cut + 3) : (a.tip ?? '');
return (
`<button class="act" data-i="${index}"${rest ? ` data-tip="${esc(rest)}"` : ''}>${esc(head)}</button>`
);
@@ -644,7 +653,7 @@ function renderActions(
// §6.2 — a drawn card has to be played or discarded before the turn can end. Keyed on
// the INTENT, not the label: matching button text would break the moment the wording
// changed, and would have caught `switch.end` too.
return actionButton(a.label, a.index);
return actionButton(a);
})
.join('') +
`</div>`,
@@ -718,12 +727,12 @@ function renderActions(
html += `</div>`;
}
if (
game.state.clock.phase === 'localOps' &&
game.state.turn.option === 'draw' &&
f.phaseKey === 'localOps' &&
f.option === 'draw' &&
!menu.options.some((i) => i.type === 'draw.end')
) {
// The hand being counted is the ACTOR's — they are the one who cannot end the turn.
const hand = (game.state.decks.hands.get(currentActor(game) ?? 0) ?? []).length;
const hand = f.handCount;
html +=
`<div class="grp"><button class="act blocked" disabled data-tip="§6.2 — you may not end a turn holding more than three cards (four with a Red Flag). Play one onto the board, or discard one face-up to a Department slot.">` +
`End Local Operations — play or discard down to three first (holding ${hand})</button></div>`;
@@ -749,28 +758,29 @@ function renderActions(
* where a rendered page would have been megabytes.
*/
function downloadSave(): void {
const data = JSON.stringify(toSave(game), null, 1);
const data = JSON.stringify(session.save(), null, 1);
const blob = new Blob([data], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `station-master-seed${game.seed}-day${game.state.clock.day}.json`;
a.download = `station-master-seed${session.seed()}-day${session.view().day}.json`;
a.click();
URL.revokeObjectURL(url);
}
function save(): void {
if (!session.capabilities.saveLocal) return;
try {
localStorage.setItem(SAVE_KEY, JSON.stringify(toSave(game)));
localStorage.setItem(SAVE_KEY, JSON.stringify(session.save()));
} catch {
// A full or disabled localStorage must not take the game down with it.
}
}
function load(): ReturnType<typeof toSave> | null {
function load(): Save | null {
try {
const raw = localStorage.getItem(SAVE_KEY);
return raw ? (JSON.parse(raw) as ReturnType<typeof toSave>) : null;
return raw ? (JSON.parse(raw) as Save) : null;
} catch {
return null;
}
@@ -810,9 +820,10 @@ if (saveBtn) saveBtn.onclick = downloadSave;
const newBtn = document.getElementById('newgame');
if (newBtn) {
newBtn.onclick = () => {
const day = game.state.clock.day;
const started = game.state.status === 'active' && (day > 1 || game.state.clock.stage > 1);
if (started && !confirm(`Forget this game (seed ${game.seed}, Day ${day}) and deal a new one?`)) return;
const f = session.view();
const day = f.day;
const started = f.status === 'active' && (day > 1 || f.stage > 1);
if (started && !confirm(`Forget this game (seed ${session.seed()}, Day ${day}) and deal a new one?`)) return;
/**
* ASK FOR THE SEED, rather than documenting a URL parameter in the title bar.
*
+172
View File
@@ -0,0 +1,172 @@
/**
* The boundary between the page and the game.
*
* The page draws a `Frame` and offers a `Menu`, and submits intents. It does not care whether the
* rules are being applied a function call away or across a network — which is the whole point:
*
* - `LocalSession` runs the engine in this browser. Solitaire, exactly as it has always worked,
* with no server involved at any point.
* - `RemoteSession` (not built yet — see `docs/architecture/multiplayer.md` Phase 2) will hold no
* authoritative state at all. It cannot: it has neither the deck order nor the other players'
* hands, and if it did the game would be cheatable.
*
* So this interface is deliberately the SMALLER of the two — everything a remote client could
* possibly offer, and nothing that only a local one can do. What a local session can do beyond it is
* declared in `capabilities`, and the page hides those controls rather than calling them and failing.
*/
import type { Intent } from '../engine/intents.ts';
import type { Frame } from '../sim/view.ts';
import type { GameConfig, PlayerIndex } from '../engine/state.ts';
import type { Game, Menu, Save } from './game.ts';
import {
actionMenu,
currentActor,
fromSave,
handPlayable,
newGame,
overHandLimit,
submit,
toSave,
undo,
view,
} from './game.ts';
/**
* What this session can do beyond the common interface.
*
* None of these survive a server. Undo would have to un-see what other players have already seen;
* a local save is meaningless when the server is the store; and dealing a new game is the lobby's
* job. The page reads these rather than assuming, so the same code drives both.
*/
export type Capabilities = {
undo: boolean;
saveLocal: boolean;
newGame: boolean;
};
export type Session = {
/** The board as this seat sees it. */
view(): Frame;
/** What this seat may do right now. */
menu(): Menu;
/** Which seat this client is playing. */
seat(): PlayerIndex;
/** Whose turn it is, or null when the game is over or waiting on nothing. */
actor(): PlayerIndex | null;
/** True when the hand is over §6.2's limit and the turn cannot be ended. */
overHandLimit(): boolean;
/** Which cards in hand are playable right now, in hand order. */
handPlayable(): boolean[];
/**
* Propose an action. Resolves false if the rules refused it.
*
* Async because a remote session must be, even though the local one answers immediately — a page
* written against a synchronous `submit` would have to be rewritten for the server.
*/
submit(intent: Intent): Promise<boolean>;
/** Called whenever something changed and the page should redraw. Returns an unsubscribe. */
subscribe(fn: () => void): () => void;
capabilities: Capabilities;
/**
* WHAT JUST HAPPENED — three transient signals the page uses to draw a moment rather than a state.
*
* They are separate from `view()` because two of them are CONSUMED: a Stage flash and a sound play
* once and are then gone, whereas the Frame can be rebuilt any number of times per render. Putting
* them on the Frame would mean re-flashing on every redraw.
*
* A remote session fills these from the server's pushes rather than from a local event log; the
* page cannot tell the difference. Whether they eventually ride on the Frame as animation hints is
* a Phase 2 question (`docs/architecture/multiplayer.md` D3).
*/
/** The narrated history, newest last. */
lines(): { text: string; tone: string }[];
/** Sounds earned since the last call. Draining. */
takeCues(): string[];
/** The timetable slot the last 1D12 filled, once. Draining. */
takeScheduled(): number | null;
/** The card most recently drawn into this seat's hand. Persists until another draw replaces it. */
justDrawn(): string | null;
};
/**
* Everything a LOCAL session can additionally do. Kept off `Session` so that reaching for one of
* these in shared page code is a type error rather than a runtime surprise against a server.
*/
export type LocalSession = Session & {
readonly game: Game;
seed(): number;
save(): Save;
/** How many intents have been submitted — what the Undo button counts down. */
steps(): number;
/** Steps back one intent by replaying the history without it. Returns false at the start. */
undo(): boolean;
restore(save: Save): void;
};
/**
* A session that owns the engine in this process.
*
* `Game` is mutated in place by `submit`, so the wrapper keeps a mutable reference rather than
* copying — `undo` and `restore` replace the whole game, which is why `game` is a getter.
*/
export function createLocalSession(seed: number, config?: GameConfig): LocalSession {
let game: Game = config ? newGame(seed, config) : newGame(seed);
const listeners = new Set<() => void>();
const changed = (): void => {
for (const fn of [...listeners]) fn();
};
return {
get game() {
return game;
},
view: () => view(game),
menu: () => actionMenu(game),
seat: () => 0,
actor: () => currentActor(game),
overHandLimit: () => overHandLimit(game),
handPlayable: () => handPlayable(game),
submit: async (intent: Intent) => {
const ok = submit(game, intent);
if (ok) changed();
return ok;
},
subscribe(fn: () => void) {
listeners.add(fn);
return () => listeners.delete(fn);
},
capabilities: { undo: true, saveLocal: true, newGame: true },
lines: () => game.log,
takeCues: () => game.cues.splice(0, game.cues.length),
takeScheduled: () => {
const slot = game.scheduled;
game.scheduled = null;
return slot;
},
justDrawn: () => game.justDrawn,
seed: () => game.seed,
save: () => toSave(game),
steps: () => game.history.length,
undo() {
const back = undo(game);
if (!back) return false;
game = back;
// The rebuilt game replays its own history, so every cue and draw in it is old news.
back.cues.length = 0;
back.scheduled = null;
back.justDrawn = null;
changed();
return true;
},
restore(save: Save) {
game = fromSave(save);
// Restoring replays the whole history and re-records every draw; none of it is news.
game.justDrawn = null;
changed();
},
};
}