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