v0.5.0 — multiplayer Phases 2 and 3: a server that runs a game and survives being restarted
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.
This commit is contained in:
+102
-5
@@ -44,9 +44,13 @@ import {
|
||||
variantLabel,
|
||||
} from '../sim/view.ts';
|
||||
import {
|
||||
DEFAULT_DAYS,
|
||||
DEFAULT_HOUSE_RULES,
|
||||
DEFAULT_MAX_COLLISIONS_PER_DAY,
|
||||
DEFAULT_MAX_COLLISIONS_TOTAL,
|
||||
HAND_LIMIT,
|
||||
LEGACY_HOUSE_RULES,
|
||||
collectiveRevenueFloor,
|
||||
houseRules,
|
||||
mainlineProfile,
|
||||
trainProfile,
|
||||
@@ -57,10 +61,19 @@ import { connectionsFor, joins, neighbour, variantsFor } from '../engine/track.t
|
||||
import { areaOf, destinationsFor, selectDestination, trainNeedingCars } from '../engine/apply.ts';
|
||||
import type { Frame } from '../sim/view.ts';
|
||||
|
||||
/**
|
||||
* The canonical "what does a fresh solitaire game look like" config — also what the New Game
|
||||
* dialog's solitaire defaults are drawn from (`main.ts`). One player, so `minCombinedRevenue` uses
|
||||
* `collectiveRevenueFloor(1, DEFAULT_DAYS)` — the same formula multiplayer configs use, just at
|
||||
* player count 1.
|
||||
*/
|
||||
export const SOLO_CONFIG: GameConfig = {
|
||||
mode: 'solitaire',
|
||||
victory: 'highestAfterDays',
|
||||
length: 'standard',
|
||||
days: DEFAULT_DAYS,
|
||||
minCombinedRevenue: collectiveRevenueFloor(1, DEFAULT_DAYS),
|
||||
maxCollisionsPerDay: DEFAULT_MAX_COLLISIONS_PER_DAY,
|
||||
maxCollisionsTotal: DEFAULT_MAX_COLLISIONS_TOTAL,
|
||||
pvpCardsAllowed: false,
|
||||
optionalRules: {
|
||||
reducedVisibility: false,
|
||||
sisterTrains: false,
|
||||
@@ -72,9 +85,29 @@ export const SOLO_CONFIG: GameConfig = {
|
||||
houseRules: DEFAULT_HOUSE_RULES,
|
||||
};
|
||||
|
||||
/**
|
||||
* Everything the New Game dialog can set for a solitaire game — the opening deal and the three
|
||||
* revenue rates (`houseRules`, unchanged), plus the four victory-condition dials added 2026-08-20.
|
||||
* Solitaire's `pvpCardsAllowed` is not here: it is forced off (`SOLO_CONFIG`), never a player choice.
|
||||
*/
|
||||
export type NewGameOptions = {
|
||||
houseRules?: HouseRuleOverrides;
|
||||
days?: number;
|
||||
minCombinedRevenue?: number;
|
||||
maxCollisionsPerDay?: number;
|
||||
maxCollisionsTotal?: number;
|
||||
};
|
||||
|
||||
/** The same config with the New Game dialog's answers in it. */
|
||||
export function configWith(rules: HouseRuleOverrides): GameConfig {
|
||||
return { ...SOLO_CONFIG, houseRules: houseRules({ houseRules: rules }) };
|
||||
export function configWith(opts: NewGameOptions): GameConfig {
|
||||
return {
|
||||
...SOLO_CONFIG,
|
||||
days: opts.days ?? SOLO_CONFIG.days,
|
||||
minCombinedRevenue: opts.minCombinedRevenue ?? SOLO_CONFIG.minCombinedRevenue,
|
||||
maxCollisionsPerDay: opts.maxCollisionsPerDay ?? SOLO_CONFIG.maxCollisionsPerDay,
|
||||
maxCollisionsTotal: opts.maxCollisionsTotal ?? SOLO_CONFIG.maxCollisionsTotal,
|
||||
houseRules: houseRules(opts.houseRules ? { houseRules: opts.houseRules } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** A group of legal actions of one kind, ready to put on screen. */
|
||||
@@ -237,6 +270,23 @@ export function newGame(seed: number, config: GameConfig = SOLO_CONFIG): Game {
|
||||
return game;
|
||||
}
|
||||
|
||||
/**
|
||||
* `newGame`'s multi-player sibling — the server session host (Phase 2) needs a `Game` wrapper for
|
||||
* more than one seat, and `newGame` hardcodes `[SOLO_PLAYER]`. Kept as a separate function rather
|
||||
* than a shared parametrized helper: `newGame` is exercised by every solitaire test and save, and
|
||||
* reordering its log-then-drain sequence to share code with this risks nothing for a marginal DRY
|
||||
* gain. `submit`/`drain`/`actionMenu`/`currentActor` all work on either unchanged, since none of them
|
||||
* know or care how many players a `Game` has.
|
||||
*/
|
||||
export function newMultiplayerGame(seed: number, config: GameConfig, playerNames: string[]): Game {
|
||||
const state = createGame({ id: `mp-${seed}`, seed, config, playerNames });
|
||||
const game: Game = { state, seed, history: [], log: [], mustPlayCard: false, cues: [], scheduled: null, justDrawn: null, announced: null };
|
||||
game.log.push({ text: 'Game Begins', tone: 'start' });
|
||||
game.log.push({ text: `${config.mode} · ${playerNames.length} players · seed ${seed}`, tone: 'quiet' });
|
||||
drain(game);
|
||||
return game;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the engine forward until it needs a decision.
|
||||
*
|
||||
@@ -530,7 +580,16 @@ export type Menu = {
|
||||
|
||||
/** The action list as the page shows it: direct actions, plus subject-then-location for the rest. */
|
||||
export function actionMenu(game: Game, seat: PlayerIndex = 0): Menu {
|
||||
const { options, groups } = actionGroups(game);
|
||||
/**
|
||||
* SEAT-SAFETY. `actionGroups`/`legalActions` answer for `currentActor(game)`, not for `seat` — there
|
||||
* is exactly one acting player at a time, so a Menu built for anyone else must show no actions at
|
||||
* all, only their own hand (found the hard way: calling this for a non-acting seat used to hand
|
||||
* back the ACTOR's legal moves paired with the WRONG seat's cards). Emptying `options`/`groups` here
|
||||
* degrades every downstream computation (`direct`, `placeable`, `makeUp`) to empty for free — the
|
||||
* hand section below still reads `seat`'s own cards correctly either way.
|
||||
*/
|
||||
const isActor = seat === currentActor(game);
|
||||
const { options, groups } = isActor ? actionGroups(game) : { options: [] as Intent[], groups: [] as ActionGroup[] };
|
||||
const direct: ActionGroup[] = [];
|
||||
const placeableByTitle = new Map<string, Map<string, Placeable>>();
|
||||
|
||||
@@ -1079,3 +1138,41 @@ export function fromSave(save: Save, config: GameConfig = SOLO_CONFIG): Game {
|
||||
}
|
||||
return game;
|
||||
}
|
||||
|
||||
/**
|
||||
* `fromSave`'s multi-player sibling — Phase 3 (`docs/architecture/multiplayer.md`): "a mid-game
|
||||
* server restart is a replay rather than a recovery" (`lobby-and-sessions.md` §6). Built on
|
||||
* `newMultiplayerGame` instead of `newGame` since a saved multiplayer game did not deal to
|
||||
* `[SOLO_PLAYER]`. The engine-version check belongs to the caller (`src/server/persistence.ts`) —
|
||||
* this function only ever reconstructs from history that is already known to have been recorded
|
||||
* under the currently-running rules.
|
||||
*
|
||||
* UNLIKE `fromSave`'s loop, this passes `actor` to `record()` (matching `submit`'s own call,
|
||||
* `game.ts` above) — found while testing Phase 3's resume path: without it, every replayed line loses
|
||||
* its "Player X" attribution and reads as anonymous "Chose to..." narration, which `record`'s own
|
||||
* comment calls "unreadable the moment there is more than one seat" — exactly the multiplayer case a
|
||||
* resumed game hits every time. `fromSave` has the same gap (it predates multiplayer and nothing ever
|
||||
* compares its output against a LIVE-played log, so it has gone unnoticed — `undo`'s rebuilt game is
|
||||
* itself `fromSave`-built, so `test/web.test.ts`'s replay-fidelity test only ever compares one
|
||||
* unattributed replay against another). Flagged in `TODO.md` rather than fixed there in this pass —
|
||||
* out of scope for Phase 3 and used far more widely, so worth its own careful look rather than a
|
||||
* touch-in-passing.
|
||||
*/
|
||||
export function fromMultiplayerSave(
|
||||
seed: number,
|
||||
config: GameConfig,
|
||||
playerNames: string[],
|
||||
history: Intent[],
|
||||
): Game {
|
||||
const game = newMultiplayerGame(seed, config, playerNames);
|
||||
for (const intent of history) {
|
||||
const actor = currentActor(game);
|
||||
if (actor === null) break;
|
||||
const result = applyIntent(game.state, actor, intent);
|
||||
if (!result.ok) break;
|
||||
game.history.push(intent);
|
||||
record(game, result.events, actor);
|
||||
drain(game);
|
||||
}
|
||||
return game;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user