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.
54 lines
2.5 KiB
TypeScript
54 lines
2.5 KiB
TypeScript
/**
|
|
* Live per-seat Frame delta — Phase 2 of `docs/architecture/multiplayer.md`.
|
|
*
|
|
* `src/sim/replay.ts`'s `compress()` looked like the thing to reuse here (D2/D3's "2.9 KB per push
|
|
* with the board omitted when unchanged" cites it) but it solves a different problem: it interns
|
|
* card/description strings across a WHOLE recorded array of frames, which only pays off when
|
|
* bundling many frames into one replay file. A live server pushes one frame at a time and has
|
|
* nothing to intern against. The part that genuinely carries over is much smaller — a one-step-back
|
|
* "null if unchanged since the last thing sent to THIS seat" check on the three fields that make up
|
|
* almost all of a Frame's size: `cells`, `facilities`, `division` (`replay.ts`'s own `keys` array).
|
|
*
|
|
* Node-free by design, unlike `replay.ts` (which imports `node:fs`) — both the server and a browser
|
|
* `RemoteSession` import this file directly.
|
|
*/
|
|
|
|
import type { CellView, DivisionView, FacilityView, Frame } from './view.ts';
|
|
|
|
/** A `Frame` with the three board-shaped fields replaced by `null` where unchanged since `previous`. */
|
|
export type FrameDelta = Omit<Frame, 'cells' | 'facilities' | 'division'> & {
|
|
cells: CellView[] | null;
|
|
facilities: FacilityView[] | null;
|
|
division: DivisionView[] | null;
|
|
};
|
|
|
|
const BOARD_KEYS = ['cells', 'facilities', 'division'] as const;
|
|
|
|
/**
|
|
* `previous` is the last Frame actually sent to THIS seat, or `null` for a first connect / a
|
|
* reconnect after a gap — Phase 2 has no persistence to replay a gap against (Phase 3), so a
|
|
* reconnect always gets a full Frame here rather than a delta.
|
|
*/
|
|
export function deltaFrame(previous: Frame | null, next: Frame): FrameDelta {
|
|
const out = { ...next } as unknown as FrameDelta;
|
|
for (const key of BOARD_KEYS) {
|
|
const unchanged = previous !== null && JSON.stringify(previous[key]) === JSON.stringify(next[key]);
|
|
(out as Record<string, unknown>)[key] = unchanged ? null : next[key];
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** The receiving side: merges a delta back onto the last full Frame this seat actually has. */
|
|
export function applyDelta(previous: Frame | null, delta: FrameDelta): Frame {
|
|
const out = { ...delta } as unknown as Frame;
|
|
for (const key of BOARD_KEYS) {
|
|
if (delta[key] === null) {
|
|
if (previous === null) {
|
|
throw new Error(`deltaFrame said "${key}" is unchanged, but there is no previous Frame to merge onto`);
|
|
}
|
|
(out as Record<string, unknown>)[key] = previous[key];
|
|
}
|
|
}
|
|
return out;
|
|
}
|