Phases 0-1 shipped in v0.4.0 (seat/identity split, per-player turn state, the Session boundary). This lands Phase 2 (server core, one game, no lobby) and Phase 3 (persistence and resumption) per docs/architecture/multiplayer.md §12. Phases 4-6 (lobby/reconnection, the 22 opponent-directed cards, StartOS packaging) are still ahead. Phase 2: src/server/session.ts hosts a game in pure logic (no sockets) on top of game.ts's existing Game/submit/currentActor/actionMenu; it verifies seat === currentActor(game) itself before calling submit, since submit() trusts its caller and a server can't. src/server/http.ts and index.ts add POST /api/game, GET /api/stream (SSE, per-seat), POST /api/intent, and static serving of dist/. src/sim/frame-delta.ts is a purpose-built per-seat board delta for one live push at a time. Found and fixed along the way: actionMenu(game, seat) only used seat for the hand field, so a server computing every connected seat's Menu would have handed the acting player's legal moves to a waiting seat. Verified with a live end-to-end smoke test (2-player game, two SSE streams, a rejected intent from the wrong seat, an idempotent resend) plus test/server/session.test.ts and test/redaction.test.ts. Not verified: an actual browser (none available in this environment). Phase 3: src/server/persistence.ts writes game.json and turn-timings.json, atomic-rewrite-then- rename. game.ts gained fromMultiplayerSave, fixing a narration-attribution bug found while testing it (fromSave's replay loop drops the actor argument, invisible in solitaire, unreadable the moment there's more than one seat — fromSave itself still has this gap, deliberately untouched). Verified live: server killed and restarted mid-game, both seats reconnected exactly where they left off. Two rules bugs found while building this: the New Train phase never implemented its car-placement round (every car of every train was placed by the Superintendent alone, in every mode, all along — now reads the round position off tray.consist.length); and victory conditions are now one shared, configurable GameConfig set across solitaire/competitive/coop instead of a fixed length lookup and a dead firstToTarget condition. Also folds in the three fixes already released on the patch line as v0.4.9b/c/d: a switching train's crew badge failing to draw once it left the Office square, an unload that always took the westmost car regardless of which was picked, and a legal decision that could render with zero buttons. docs/testing/0.5.0-test-plan.md and three reported-bug save files (docs/station-master-seed*.json) included for reproducibility. tools/jitsi-harness/ deliberately left untracked — unrelated side-project work, not part of this release. 635 tests, 0 failures.
429 lines
16 KiB
TypeScript
429 lines
16 KiB
TypeScript
/**
|
|
* Game setup — rules §4.
|
|
*
|
|
* Builds the initial state deterministically from a seed. Part of component 2; the D12 rolls
|
|
* come from component 7.
|
|
*/
|
|
|
|
import {
|
|
ACTION_CARDS,
|
|
ENHANCEMENT_CARDS,
|
|
EXTRA_TRAINS,
|
|
FREIGHT_PROFILES,
|
|
MAINLINE_MODIFIER_CARDS,
|
|
MANEUVER_CARDS,
|
|
SPACE_USE_CARDS,
|
|
MOVES_PER_LOCAL_OPS,
|
|
MODIFIER_PROFILES,
|
|
OFFICE_PROFILES,
|
|
OPENING_DEALS,
|
|
MAINLINE_PROFILES,
|
|
houseRules,
|
|
mainlineProfile,
|
|
TRACK_CARDS,
|
|
ROLLING_STOCK_SUPPLY,
|
|
STAGES_PER_DAY,
|
|
TIMETABLED_TRAINS,
|
|
crewTrayCount,
|
|
mainlineCardCount,
|
|
officeProfile,
|
|
} from './content.ts';
|
|
import type { Rng } from './rng.ts';
|
|
import { createRng } from './rng.ts';
|
|
import type {
|
|
Card,
|
|
CardId,
|
|
DivisionNode,
|
|
SeatIndex,
|
|
GameConfig,
|
|
GameState,
|
|
OfficeArea,
|
|
PlayerIndex,
|
|
RollingStock,
|
|
TrackCard,
|
|
TrayId,
|
|
} from './state.ts';
|
|
import { coordKey, freshTurns } from './state.ts';
|
|
|
|
export type SetupOptions = {
|
|
id: string;
|
|
seed: number;
|
|
config: GameConfig;
|
|
playerNames: string[];
|
|
};
|
|
|
|
/** Builds the 52-card Home Office deck (§12.1). Unshuffled; caller shuffles with the seeded RNG. */
|
|
export function buildDeck(mode: GameConfig['mode'] = 'competitive', pvpCardsAllowed = false): Card[] {
|
|
/**
|
|
* THE 22 OPPONENT-DIRECTED CARDS ARE OUT OF EVERY DECK REGARDLESS OF `pvpCardsAllowed`, for now.
|
|
*
|
|
* Q6 took them out of solitaire because they have no legal target in a one-player game. They are
|
|
* out of the competitive deck too because `checkPlay` answers both categories `NOT_IMPLEMENTED`:
|
|
* leaving them in would make ~9% of draws reject outright, which is worse than not dealing them.
|
|
* `pvpCardsAllowed` (`GameConfig`, wired 2026-08-20) is the player-facing toggle, but ANDing it
|
|
* with `cardsImplemented` keeps it inert until Phase 5 actually builds the cards — flipping
|
|
* `pvpCardsAllowed` on today must not turn a working game into one where ~9% of draws reject.
|
|
*
|
|
* AND SO ARE THE SEVEN CARDS THAT ANSWER THEM — see below. They used to be dealt and sit dormant,
|
|
* which is the same dead draw by another name. Recorded in TODO.md as multiplayer work.
|
|
*
|
|
* `mode` is still taken so the signature does not move when they return; it is deliberately unused
|
|
* for these two categories today.
|
|
*/
|
|
void mode;
|
|
const cardsImplemented = false;
|
|
const opponentCardsInDeck = pvpCardsAllowed && cardsImplemented;
|
|
const cards: Card[] = [];
|
|
let n = 0;
|
|
const push = (kind: Card['kind']): void => {
|
|
cards.push({ id: `c${n++}`, kind });
|
|
};
|
|
|
|
for (const t of TIMETABLED_TRAINS) push({ kind: 'timetabledTrain', number: t.number });
|
|
for (const t of EXTRA_TRAINS) push({ kind: 'extraTrain', number: t.number });
|
|
for (const o of OFFICE_PROFILES) {
|
|
for (let i = 0; i < o.copiesInDeck; i++) push({ kind: 'office', tier: o.tier });
|
|
}
|
|
for (const f of FREIGHT_PROFILES) {
|
|
for (let i = 0; i < f.copies; i++) push({ kind: 'freightFacility', facility: f.kind });
|
|
}
|
|
for (const m of MODIFIER_PROFILES) {
|
|
for (let i = 0; i < m.copies; i++) push({ kind: 'modifier', modifier: m.kind });
|
|
}
|
|
if (opponentCardsInDeck) {
|
|
for (const c of SPACE_USE_CARDS) {
|
|
for (let i = 0; i < c.copies; i++) push({ kind: 'spaceUse', key: c.key });
|
|
}
|
|
}
|
|
/**
|
|
* A DEFENCE GOES OUT WITH THE ATTACK IT ANSWERS.
|
|
*
|
|
* Facing Point Locks (both kinds), the Water Column and the Overpass exist only to answer Derail,
|
|
* a Watertower and a Railroad Crossing — all of which are among the 22 opponent-directed cards
|
|
* held out above. Left in, they are dead draws for exactly the same reason the attacks would be,
|
|
* and there are 7 of them. `SimpleCard.answers` names the pairing on the card itself, so they come
|
|
* back together the moment `opponentCardsInDeck` does.
|
|
*/
|
|
const dealt = (c: { answers?: string }): boolean => opponentCardsInDeck || !c.answers;
|
|
for (const c of ENHANCEMENT_CARDS) {
|
|
if (!dealt(c)) continue;
|
|
for (let i = 0; i < c.copies; i++) push({ kind: 'enhancement', key: c.key });
|
|
}
|
|
for (const c of MAINLINE_MODIFIER_CARDS) {
|
|
if (!dealt(c)) continue;
|
|
for (let i = 0; i < c.copies; i++) push({ kind: 'mainlineModifier', key: c.key });
|
|
}
|
|
for (const c of MANEUVER_CARDS) {
|
|
for (let i = 0; i < c.copies; i++) push({ kind: 'maneuver', key: c.key });
|
|
}
|
|
if (opponentCardsInDeck) {
|
|
for (const c of ACTION_CARDS) {
|
|
for (let i = 0; i < c.copies; i++) push({ kind: 'action', key: c.key });
|
|
}
|
|
}
|
|
// Track is IN the deck, 96 cards of it — the single largest category, and the reason building a
|
|
// district costs you the industry or train you did not draw instead.
|
|
for (const t of TRACK_CARDS) {
|
|
for (let i = 0; i < t.copiesInDeck; i++) push({ kind: 'track', geometry: t.geometry, hand: t.hand });
|
|
}
|
|
|
|
return cards;
|
|
}
|
|
|
|
/** The Division Yard starts with every rolling stock piece (§4.9). */
|
|
export function buildRollingStock(): RollingStock[] {
|
|
const out: RollingStock[] = [];
|
|
for (const s of ROLLING_STOCK_SUPPLY) {
|
|
for (let i = 0; i < s.loaded; i++) out.push({ type: s.type, loaded: true });
|
|
for (let i = 0; i < s.empty; i++) out.push({ type: s.type, loaded: false });
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* A player's opening three cards: Limits, Whistle Post, Limits, in one horizontal row (§4.2).
|
|
*
|
|
* Gap 8 — the Office card carries a plain junction stub above and below, so Secondary Track can
|
|
* branch from the opening Stage. The stubs are NOT turnouts: §A.1's directional rule governs
|
|
* drawn turnout cards only.
|
|
*/
|
|
function buildOfficeArea(seat: SeatIndex): OfficeArea {
|
|
const row = 0;
|
|
const officeCoord = { row, col: 0 };
|
|
const limitsWest = { row, col: -1 };
|
|
const limitsEast = { row, col: 1 };
|
|
|
|
const officeCard: TrackCard = {
|
|
geometry: { kind: 'office' },
|
|
baseOperationalRail: true,
|
|
standing: [],
|
|
standingWest: 0,
|
|
facility: buildPassengerFacility('whistlePost'),
|
|
modifiers: [],
|
|
enhancements: [],
|
|
};
|
|
|
|
const limitsCard = (): TrackCard => ({
|
|
geometry: { kind: 'limits' },
|
|
baseOperationalRail: true,
|
|
standing: [],
|
|
standingWest: 0,
|
|
facility: null,
|
|
modifiers: [],
|
|
enhancements: [],
|
|
});
|
|
|
|
const grid = new Map<string, TrackCard>();
|
|
grid.set(coordKey(officeCoord), officeCard);
|
|
grid.set(coordKey(limitsWest), limitsCard());
|
|
grid.set(coordKey(limitsEast), limitsCard());
|
|
|
|
return {
|
|
seat,
|
|
tier: 'whistlePost',
|
|
grid,
|
|
officeCoord,
|
|
runningRow: row,
|
|
limitsWest,
|
|
limitsEast,
|
|
adOccupancy: [],
|
|
heldAtLimits: [],
|
|
dispatchUsedToday: [],
|
|
};
|
|
}
|
|
|
|
/**
|
|
* A Whistle Post is not a Passenger Facility (§9), so it has no Porters and no slots — but the
|
|
* Office card still carries a facility record so an upgrade is a property change rather than a
|
|
* card swap (game-state.md constraint 9).
|
|
*/
|
|
function buildPassengerFacility(tier: Parameters<typeof officeProfile>[0]): NonNullable<TrackCard['facility']> {
|
|
const p = officeProfile(tier);
|
|
return {
|
|
kind: 'passenger',
|
|
subtype: 'office',
|
|
allows: { outbound: p.isPassengerFacility, inbound: p.isPassengerFacility },
|
|
outboundBox: [],
|
|
inboundBox: [],
|
|
capacity: { outbound: p.passengerOut, inbound: p.passengerIn },
|
|
// No freight pipeline: a Depot, Station and Terminal work passengers only, and the MEN | AT |
|
|
// WORK sign is printed "For Freight Facilities" (rules-v0.2.md:452). This was a three-slot array
|
|
// of nulls, which read as "three empty boxes" to every renderer that looked.
|
|
menAtWork: null,
|
|
industryTrack: { cars: [] },
|
|
laborers: 0,
|
|
porters: p.porters,
|
|
usedThisStage: { laborers: 0, porters: 0 },
|
|
};
|
|
}
|
|
|
|
/**
|
|
* §4.3-4.4 — the west-to-east chain, with a Division Point beyond each end.
|
|
*
|
|
* Each Mainline card is now a TYPE drawn at random (§4.2, "a Mainline card is drawn at random and
|
|
* placed between each player"), which is what gives the Division its terrain and therefore its
|
|
* crossing times.
|
|
*/
|
|
function buildDivision(players: number, rng: Rng): DivisionNode[] {
|
|
const nodes: DivisionNode[] = [];
|
|
const kinds = MAINLINE_PROFILES.map((m) => m.kind);
|
|
const mainline = (): DivisionNode => {
|
|
const card = kinds[rng.nextInt(kinds.length)]!;
|
|
const node: DivisionNode = { kind: 'mainline', card, transits: [] };
|
|
if (mainlineProfile(card).speed.kind === 'grade') {
|
|
/**
|
|
* SETTLED, not provisional (v0.5.0, Jesse's call) — this overrides the card's own printed
|
|
* "Player sets orientation". A Heavy Grade sits on the shared west-to-east chain BETWEEN two
|
|
* players (§4.2), or beyond an end Division Point next to one — never inside one player's own
|
|
* district — so whichever direction climbs advantages one neighbour over the other, and there
|
|
* is no single player who owns that call fairly. Orientation is rolled from the seed instead,
|
|
* identically for solitaire and multiplayer, and this is not expected to change when setup
|
|
* eventually gains an interactive phase for other decisions. See implications.md §10 Q11.
|
|
*/
|
|
node.gradeUp = rng.nextInt(2) === 0 ? 'east' : 'west';
|
|
}
|
|
return node;
|
|
};
|
|
|
|
nodes.push({ kind: 'divisionPoint', side: 'west', holding: [] });
|
|
for (let p = 0; p < players; p++) {
|
|
nodes.push(mainline());
|
|
nodes.push({ kind: 'office', seat: p });
|
|
}
|
|
nodes.push(mainline());
|
|
nodes.push({ kind: 'divisionPoint', side: 'east', holding: [] });
|
|
|
|
return nodes;
|
|
}
|
|
|
|
export function createGame(opts: SetupOptions): GameState {
|
|
const { id, seed, config, playerNames } = opts;
|
|
if (playerNames.length < 1) throw new Error('a game needs at least one player');
|
|
if (config.mode === 'solitaire' && playerNames.length !== 1) {
|
|
throw new Error('solitaire is a one-player mode');
|
|
}
|
|
|
|
const rng = createRng(seed);
|
|
const playerCount = playerNames.length;
|
|
|
|
const players = playerNames.map((name, index) => ({ index, name, revenue: 0 }));
|
|
|
|
// 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));
|
|
// §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.
|
|
const divisionRolls = players.map(() => rng.d12());
|
|
const superRolls = players.map(() => rng.d12());
|
|
const superintendent = argmax(superRolls);
|
|
|
|
/**
|
|
* §4.4 — THE OPENING D12 DECIDES WHO SITS WHERE.
|
|
*
|
|
* `seating[seat] = player`, and seat 0 is the WESTERN end of the chain (`buildDivision` lays the
|
|
* Western Division Point, then office 0, and finishes at the Eastern one). So the highest roll
|
|
* takes the last seat — "highest is the Eastern Division Point" — and the lowest ends up beside
|
|
* the Western Division Point, which is the rule's other named position.
|
|
*
|
|
* The rule names only those two ends, because at a table the players are already sitting in a
|
|
* chain and the roll only says which way round it is. There is no physical table here, so the
|
|
* roll orders everybody: ascending by roll, west to east. It uses a number every player is
|
|
* already told to roll, and it makes the roll matter to more than the winner.
|
|
*
|
|
* Ties break toward the LOWER player index sitting further east, which is the same convention
|
|
* `argmax` uses for the Superintendent roll on the line above — first max wins.
|
|
*
|
|
* This was `void divisionRolls` until v0.4.1: the roll was drawn and discarded, and seating was
|
|
* the identity mapping. Turning it on is what makes seat and player genuinely different at
|
|
* runtime rather than only in the type names.
|
|
*/
|
|
const seating: PlayerIndex[] = players
|
|
.map((_, p) => p)
|
|
.sort((a, b) => divisionRolls[a]! - divisionRolls[b]! || b - a);
|
|
|
|
/**
|
|
* §4.6-4.7 — THE OPENING DEAL, in whichever of the three shapes this game was dealt with.
|
|
*
|
|
* `threeRandom` and `sixRandom` deal off ONE shuffled deck; `threeTrackThreeOther` shuffles track
|
|
* separately and deals three of each, then shuffles the remainder back together so the game runs
|
|
* off one deck from the first draw onward either way. `content.ts` says what each option is for.
|
|
*
|
|
* A player dealt six holds six against a limit of three, on purpose: the first turn is spent
|
|
* choosing what to keep — draw as usual, then play or discard down to three (§6.2, enforced on
|
|
* `draw.end`, which needs no special case for this). A player dealt three is already at the limit.
|
|
*
|
|
* §4.7 — "starting from the Superintendent, deal each player…", which proceeds round the table and
|
|
* is therefore SEAT order, not player order. That is the same in all three shapes.
|
|
*/
|
|
const rules = houseRules(config);
|
|
const deal = OPENING_DEALS[rules.startingHand];
|
|
const deck = buildDeck(config.mode, config.pvpCardsAllowed);
|
|
const cards = new Map<CardId, Card>();
|
|
for (const c of deck) cards.set(c.id, c);
|
|
|
|
const isTrack = (id: CardId): boolean => cards.get(id)?.kind.kind === 'track';
|
|
const ids = deck.map((c) => c.id);
|
|
const superSeat = seating.indexOf(superintendent);
|
|
const hands = new Map<PlayerIndex, CardId[]>();
|
|
const dealtTo = (i: number): PlayerIndex => seating[(superSeat + i) % playerCount]!;
|
|
|
|
let shuffled: CardId[];
|
|
if (deal.any > 0) {
|
|
// One deck, one shuffle, the top cards off it — the prototype's own deal.
|
|
const single = rng.shuffle(ids);
|
|
let cut = 0;
|
|
for (let i = 0; i < playerCount; i++) {
|
|
hands.set(dealtTo(i), single.slice(cut, cut + deal.any));
|
|
cut += deal.any;
|
|
}
|
|
shuffled = single.slice(cut);
|
|
} else {
|
|
// Shuffled in a fixed order — track first — so the RNG stream is deterministic for a given seed.
|
|
const trackPile = rng.shuffle(ids.filter(isTrack));
|
|
const otherPile = rng.shuffle(ids.filter((id) => !isTrack(id)));
|
|
let trackCursor = 0;
|
|
let otherCursor = 0;
|
|
for (let i = 0; i < playerCount; i++) {
|
|
hands.set(dealtTo(i), [
|
|
...trackPile.slice(trackCursor, trackCursor + deal.track),
|
|
...otherPile.slice(otherCursor, otherCursor + deal.other),
|
|
]);
|
|
trackCursor += deal.track;
|
|
otherCursor += deal.other;
|
|
}
|
|
// The remainder goes back into ONE deck for the rest of the game — the split is an opening-deal
|
|
// device only, so track competes for the draw exactly as before from the first draw onward.
|
|
shuffled = rng.shuffle([...trackPile.slice(trackCursor), ...otherPile.slice(otherCursor)]);
|
|
}
|
|
|
|
// §4.6-4.7 — three cards turned face up beside the deck. Each is the bottom of a Department pile
|
|
// that grows as players discard onto it. Taken after the recombination, so a Department slot can
|
|
// show track like any other card.
|
|
let cursor = 0;
|
|
const departments: CardId[][] = [[], [], []];
|
|
for (const pile of departments) {
|
|
const card = shuffled[cursor++];
|
|
if (card) pile.push(card);
|
|
}
|
|
const homeOffice = shuffled.slice(cursor);
|
|
|
|
const redFlags = new Map<PlayerIndex, boolean>();
|
|
for (let p = 0; p < playerCount; p++) {
|
|
redFlags.set(p, config.optionalRules.emergencyToolbox);
|
|
}
|
|
|
|
// §4.9 - Crew Trays near the turn sheet. Scarcity is an explicit mechanic (§7).
|
|
const freeTrays: TrayId[] = [];
|
|
for (let i = 0; i < crewTrayCount(playerCount); i++) freeTrays.push(`tray${i}`);
|
|
|
|
const timetable: (number | null)[] = new Array(STAGES_PER_DAY).fill(null);
|
|
|
|
return {
|
|
id,
|
|
config,
|
|
seed,
|
|
rngState: rng.getState(),
|
|
players,
|
|
seating,
|
|
openingRolls: { division: divisionRolls, superintendent: superRolls },
|
|
division: { nodes: buildDivision(playerCount, rng) },
|
|
officeAreas,
|
|
trays: new Map(),
|
|
freeTrays,
|
|
cards,
|
|
decks: { homeOffice, departments, salvageYard: [], hands, redFlags },
|
|
yards: { divisionYard: buildRollingStock(), classificationYard: [] },
|
|
timetable,
|
|
pendingExtras: [],
|
|
pendingSecondSections: [],
|
|
clock: {
|
|
day: 1,
|
|
stage: 1,
|
|
phase: 'localOps',
|
|
currentActor: superintendent,
|
|
pendingDecision: null,
|
|
clearanceRuling: null,
|
|
superintendent,
|
|
actorOffset: 0,
|
|
},
|
|
turns: freshTurns(playerCount, MOVES_PER_LOCAL_OPS),
|
|
movedThisPhase: new Set(),
|
|
collisionsToday: 0,
|
|
collisionsTotal: 0,
|
|
status: 'active',
|
|
outcome: null,
|
|
};
|
|
}
|
|
|
|
function argmax(values: number[]): number {
|
|
let best = 0;
|
|
for (let i = 1; i < values.length; i++) {
|
|
if (values[i]! > values[best]!) best = i;
|
|
}
|
|
return best;
|
|
}
|
|
|
|
export { mainlineCardCount };
|