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
+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 };
}