v0.4.0 — multiplayer Phases 0 and 1: seat and player split apart, turn state per player, the page behind a Session, and eight seat/player mix-ups fixed with tests that fail without them
This commit is contained in:
+48
-31
@@ -36,10 +36,10 @@ import type { Direction } from './content.ts';
|
||||
import type { GameEvent } from './events.ts';
|
||||
// `trainNeedingCars` lives in apply.ts beside `check`'s copy of the same question, so the phase and
|
||||
// the legality test cannot disagree about which train is being assembled.
|
||||
import { areaOf, trainNeedingCars } from './apply.ts';
|
||||
import { areaAtSeat, areaOf, trainNeedingCars } from './apply.ts';
|
||||
import { legalActions } from './legal.ts';
|
||||
import type { CrewTray, DivisionNode, GameState, PlayerIndex, RollingStock, TrayId } from './state.ts';
|
||||
import { coordKey, freshTurn, subdivisions, totalRevenue } from './state.ts';
|
||||
import type { CrewTray, DivisionNode, GameState, PlayerIndex, RollingStock, SeatIndex, TrayId } from './state.ts';
|
||||
import { coordKey, freshTurns, playerAtSeat, subdivisions, totalRevenue, turnOf } from './state.ts';
|
||||
|
||||
export type AdvanceResult = {
|
||||
events: GameEvent[];
|
||||
@@ -61,8 +61,8 @@ function movesForStage(s: GameState): number {
|
||||
// Division navigation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function nodeIndexOfOffice(s: GameState, owner: PlayerIndex): number {
|
||||
return s.division.nodes.findIndex((n) => n.kind === 'office' && n.owner === owner);
|
||||
function nodeIndexOfOffice(s: GameState, seat: SeatIndex): number {
|
||||
return s.division.nodes.findIndex((n) => n.kind === 'office' && n.seat === seat);
|
||||
}
|
||||
|
||||
const step = (d: Direction): number => (d === 'east' ? 1 : -1);
|
||||
@@ -101,31 +101,41 @@ function playerPhase(
|
||||
events: GameEvent[],
|
||||
phase: 'localOps' | 'loadUnload',
|
||||
): AdvanceResult {
|
||||
/**
|
||||
* THE CURSOR STILL WALKS ONE PLAYER AT A TIME.
|
||||
*
|
||||
* Turn state is now per-player, but only the player at `actorOffset` is ever asked to act, so this
|
||||
* behaves exactly as it did when there was a single `TurnState` — which is the point: the model
|
||||
* change is neutral, and letting local work happen off-cursor is a later change to `isActor`
|
||||
* alone (`docs/architecture/multiplayer.md` D19).
|
||||
*/
|
||||
const actor = actorAt(s, s.clock.actorOffset);
|
||||
const turn = turnOf(s, actor);
|
||||
|
||||
// A Freight Agent operation is a whole action in itself, so the turn ends with it (§6.3).
|
||||
if (phase === 'localOps' && s.turn.option === 'freightAgent' && s.turn.freightAgentUsed) {
|
||||
s.turn.done = true;
|
||||
if (phase === 'localOps' && turn.option === 'freightAgent' && turn.freightAgentUsed) {
|
||||
turn.done = true;
|
||||
}
|
||||
// Spending the last Move ends a switching turn without needing an explicit end (§6.1).
|
||||
if (phase === 'localOps' && s.turn.option === 'switch' && s.turn.movesRemaining === 0) {
|
||||
s.turn.done = true;
|
||||
if (phase === 'localOps' && turn.option === 'switch' && turn.movesRemaining === 0) {
|
||||
turn.done = true;
|
||||
}
|
||||
|
||||
if (!s.turn.done) {
|
||||
s.clock.currentActor = actorAt(s, s.clock.actorOffset);
|
||||
if (!turn.done) {
|
||||
s.clock.currentActor = actor;
|
||||
|
||||
// SAFETY NET. If the actor has no legal action at all, the turn ends rather than deadlocking.
|
||||
// This should never fire — an option with no follow-up is already unavailable (§6, apply.ts) —
|
||||
// but a rules gap that stranded a player would otherwise hang the game rather than fail
|
||||
// visibly, and a hung game is far harder to diagnose than a forfeited turn.
|
||||
if (legalActions(s, s.clock.currentActor).length === 0) {
|
||||
s.turn.done = true;
|
||||
turn.done = true;
|
||||
} else {
|
||||
return { events, needsInput: true };
|
||||
}
|
||||
}
|
||||
|
||||
s.clock.actorOffset += 1;
|
||||
s.turn = freshTurn(movesForStage(s));
|
||||
|
||||
if (s.clock.actorOffset >= s.players.length) {
|
||||
return { events: [...events, ...enterPhase(s, nextPhase(phase))], needsInput: false };
|
||||
@@ -158,7 +168,9 @@ function nextPhase(p: GameState['clock']['phase']): GameState['clock']['phase']
|
||||
function enterPhase(s: GameState, phase: GameState['clock']['phase']): GameEvent[] {
|
||||
s.clock.phase = phase;
|
||||
s.clock.actorOffset = 0;
|
||||
s.turn = freshTurn(movesForStage(s));
|
||||
// Every player gets a turn at phase entry, not one at a time as the cursor reaches them. With the
|
||||
// cursor still walking sequentially this is indistinguishable from the old behaviour.
|
||||
s.turns = freshTurns(s.players.length, movesForStage(s));
|
||||
s.clock.currentActor = phase === 'mainline' ? null : actorAt(s, 0);
|
||||
return [
|
||||
{ type: 'phaseBegan', phase },
|
||||
@@ -329,7 +341,8 @@ function mainlinePhase(s: GameState, events: GameEvent[]): AdvanceResult {
|
||||
(tray.position.coord.row === where.coord.row && tray.position.coord.col === where.coord.col));
|
||||
if (moved !== 'expedited' && stillThere) {
|
||||
tray.stopPointClaimed = true;
|
||||
const owner = tray.position.at === 'grid' ? tray.position.owner : 0;
|
||||
// The point goes to whoever is SITTING in the district it stopped in.
|
||||
const owner = tray.position.at === 'grid' ? playerAtSeat(s, tray.position.seat) : 0;
|
||||
const label =
|
||||
tray.position.at === 'grid'
|
||||
? `(${tray.position.coord.row},${tray.position.coord.col})`
|
||||
@@ -431,8 +444,9 @@ function spendDispatchBonus(
|
||||
const mine = tray.trainNumber ?? 99;
|
||||
const theirs = other.trainNumber ?? 99;
|
||||
|
||||
const area = s.officeAreas.get(s.clock.superintendent);
|
||||
if (!area) return 0;
|
||||
// The Fedora is held by a PLAYER, and the dispatch devices are installed in an Office Area, which
|
||||
// is keyed by SEAT. Indexing one with the other is right only while seating is the identity map.
|
||||
const area = areaOf(s, s.clock.superintendent);
|
||||
|
||||
// Best device first — Radio (+12) beats Telephone (+8) beats Telegraph (+4).
|
||||
for (const key of ['radio', 'telephone', 'telegraph'] as const) {
|
||||
@@ -503,8 +517,8 @@ function moveTrain(
|
||||
// Without this a train that arrives at an Office never leaves, holding an A/D track forever and
|
||||
// colliding with every train that follows it.
|
||||
if (tray.position.at === 'grid') {
|
||||
const owner = tray.position.owner;
|
||||
const area = areaOf(s, owner);
|
||||
const seat = tray.position.seat;
|
||||
const area = areaAtSeat(s, seat);
|
||||
// Only a train at the Office itself is eligible; one on Secondary Track is not (§8.1, Gap 2b).
|
||||
if (
|
||||
tray.position.coord.row !== area.officeCoord.row ||
|
||||
@@ -533,7 +547,7 @@ function moveTrain(
|
||||
return 'held';
|
||||
}
|
||||
|
||||
const officeIndex = nodeIndexOfOffice(s, owner);
|
||||
const officeIndex = nodeIndexOfOffice(s, seat);
|
||||
const target = officeIndex + dir;
|
||||
const node = s.division.nodes[target];
|
||||
if (!node) return 'held';
|
||||
@@ -551,7 +565,7 @@ function moveTrain(
|
||||
from: 'the Office',
|
||||
to: 'the Mainline',
|
||||
});
|
||||
awardDeparture(s, owner, tray, events);
|
||||
awardDeparture(s, seat, tray, events);
|
||||
return 'moved';
|
||||
}
|
||||
|
||||
@@ -566,7 +580,7 @@ function moveTrain(
|
||||
*/
|
||||
if (node.kind === 'divisionPoint') {
|
||||
area.adOccupancy = area.adOccupancy.filter((t) => t !== id);
|
||||
awardDeparture(s, owner, tray, events);
|
||||
awardDeparture(s, seat, tray, events);
|
||||
retireTrain(s, id, tray, events);
|
||||
return 'moved';
|
||||
}
|
||||
@@ -654,7 +668,7 @@ function moveTrain(
|
||||
}
|
||||
|
||||
if (dest.kind === 'office') {
|
||||
return arriveAtOffice(s, id, tray, dest.owner, events);
|
||||
return arriveAtOffice(s, id, tray, dest.seat, events);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -758,10 +772,10 @@ function arriveAtOffice(
|
||||
s: GameState,
|
||||
id: TrayId,
|
||||
tray: CrewTray,
|
||||
owner: PlayerIndex,
|
||||
seat: SeatIndex,
|
||||
events: GameEvent[],
|
||||
): MoveOutcome {
|
||||
const area = areaOf(s, owner);
|
||||
const area = areaAtSeat(s, seat);
|
||||
const capacity = officeProfile(area.tier).adTracks;
|
||||
const hasEnhancement = (key: string): boolean =>
|
||||
[...area.grid.values()].some((c) => c.enhancements.includes(key));
|
||||
@@ -773,7 +787,7 @@ function arriveAtOffice(
|
||||
for (const [key, card] of area.grid) {
|
||||
if (!card.enhancements.includes('yardOffice')) continue;
|
||||
const [row, col] = key.split(',').map(Number);
|
||||
tray.position = { at: 'grid', owner, coord: { row: row!, col: col! } };
|
||||
tray.position = { at: 'grid', seat, coord: { row: row!, col: col! } };
|
||||
events.push({
|
||||
type: 'trainDiverted',
|
||||
trainNumber: tray.trainNumber ?? 0,
|
||||
@@ -799,7 +813,7 @@ function arriveAtOffice(
|
||||
});
|
||||
return 'moved';
|
||||
}
|
||||
collide(s, owner, [id], events, 'no free A/D track', 'the Office');
|
||||
collide(s, playerAtSeat(s, seat), [id], events, 'no free A/D track', 'the Office');
|
||||
return 'moved';
|
||||
}
|
||||
|
||||
@@ -808,7 +822,7 @@ function arriveAtOffice(
|
||||
const first = area.heldAtLimits.shift()!;
|
||||
area.adOccupancy.push(first);
|
||||
const held = s.trays.get(first);
|
||||
if (held) held.position = { at: 'grid', owner, coord: area.officeCoord };
|
||||
if (held) held.position = { at: 'grid', seat, coord: area.officeCoord };
|
||||
}
|
||||
area.heldAtLimits = area.heldAtLimits.filter((t) => t !== id);
|
||||
|
||||
@@ -816,12 +830,12 @@ function arriveAtOffice(
|
||||
// is not expecting them (§A.4), so this is a collision too, not a coupling.
|
||||
const officeCard = area.grid.get(coordKey(area.officeCoord));
|
||||
if (officeCard && officeCard.standing.length > 0) {
|
||||
collide(s, owner, [id], events, 'cars fouling the Running Track', 'the Running Track');
|
||||
collide(s, playerAtSeat(s, seat), [id], events, 'cars fouling the Running Track', 'the Running Track');
|
||||
return 'moved';
|
||||
}
|
||||
|
||||
area.adOccupancy.push(id);
|
||||
tray.position = { at: 'grid', owner, coord: area.officeCoord };
|
||||
tray.position = { at: 'grid', seat, coord: area.officeCoord };
|
||||
events.push({
|
||||
type: 'trainArrived',
|
||||
trainNumber: tray.trainNumber ?? 0,
|
||||
@@ -913,11 +927,14 @@ function collide(
|
||||
*/
|
||||
function awardDeparture(
|
||||
s: GameState,
|
||||
owner: PlayerIndex,
|
||||
seat: SeatIndex,
|
||||
tray: CrewTray,
|
||||
events: GameEvent[],
|
||||
): void {
|
||||
if (tray.trainNumber === null) return;
|
||||
// Paid to whoever OCCUPIES the Office the train left, not to the seat's index. Identical today,
|
||||
// and the difference is the whole point of the seat/player split.
|
||||
const owner = playerAtSeat(s, seat);
|
||||
const p = s.players[owner];
|
||||
if (!p) return;
|
||||
p.revenue += 1;
|
||||
|
||||
Reference in New Issue
Block a user