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:
+116
-4
@@ -11,21 +11,31 @@ import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { advance, pump } from '../src/engine/advance.ts';
|
||||
import { areaAtSeat, areaOf } from '../src/engine/apply.ts';
|
||||
import { applyIntent, areaAtSeat, areaOf } from '../src/engine/apply.ts';
|
||||
import { STAGES_PER_SHIFT, crewTrayCount } from '../src/engine/content.ts';
|
||||
import { createGame } from '../src/engine/setup.ts';
|
||||
import type { GameConfig, GameState, PlayerIndex } from '../src/engine/state.ts';
|
||||
import { coordKey, playerAtSeat, seatOf, subdivisions } from '../src/engine/state.ts';
|
||||
import { coordKey, playerAtSeat, playerLeftOf, seatOf, subdivisions } from '../src/engine/state.ts';
|
||||
import { developerBot, playGame } from '../src/sim/bot.ts';
|
||||
import { snapshot } from '../src/sim/view.ts';
|
||||
import { impediments } from '../src/sim/narrate.ts';
|
||||
import { readFileSync, readdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { actionMenu } from '../src/web/game.ts';
|
||||
import type { Game } from '../src/web/game.ts';
|
||||
|
||||
/** The thin wrapper `actionMenu` expects, built directly around an already-created multi-player state
|
||||
* — `newGame` (game.ts) hardcodes one player, so it cannot construct this for a multi-seat game. */
|
||||
const wrap = (s: GameState): Game =>
|
||||
({ state: s, seed: s.seed, history: [], log: [], mustPlayCard: false, cues: [], scheduled: null, justDrawn: null, announced: null });
|
||||
|
||||
const competitive: GameConfig = {
|
||||
mode: 'competitive',
|
||||
victory: 'highestAfterDays',
|
||||
length: 'standard',
|
||||
days: 5,
|
||||
minCombinedRevenue: 0,
|
||||
maxCollisionsPerDay: 0,
|
||||
maxCollisionsTotal: 0,
|
||||
pvpCardsAllowed: false,
|
||||
optionalRules: { reducedVisibility: false, sisterTrains: false, employeeRotation: false, emergencyToolbox: false },
|
||||
};
|
||||
|
||||
@@ -474,6 +484,9 @@ describe('the view shows one seat at a time', () => {
|
||||
* which is a list of squares that do not exist on the board they are looking at.
|
||||
*/
|
||||
const s = game(3);
|
||||
// A real floor to pace against — the shared `competitive` fixture leaves it off (0) since most
|
||||
// tests in this file don't care, but pacing against "off" is trivially "always on pace" (view.ts).
|
||||
s.config.minCombinedRevenue = 10;
|
||||
s.players[0]!.revenue = 1;
|
||||
s.players[1]!.revenue = 9;
|
||||
s.players[2]!.revenue = 17;
|
||||
@@ -600,3 +613,102 @@ describe('scoring lands on the right seat', () => {
|
||||
assert.notEqual(areaAtSeat(s, 1), areaAtSeat(s, 2), 'two seats share one Office Area object');
|
||||
});
|
||||
});
|
||||
|
||||
describe('the New Train phase car-placement round rotates (§7, Gap 9)', () => {
|
||||
/**
|
||||
* REGRESSION. `newTrainPhase` used to hand the whole car-filling loop to a fixed actor —
|
||||
* `actorOffset` is reset to 0 entering the phase and was never incremented, so `actorAt(s, 0)`
|
||||
* always resolved to the Superintendent, who placed every car of every train alone. The written
|
||||
* rule (`rules-v0.2.md` §7, Gap 9) is explicit: "starting with the Superintendent and working
|
||||
* left, each player may place ONE car... the round repeats... until the consist is full," with a
|
||||
* worked example showing seats alternating. The fix reads the round position off
|
||||
* `tray.consist.length` instead, which is already exactly that counter and resets per train.
|
||||
*/
|
||||
it('cycles Superintendent-then-left, one car per player, wrapping as the round repeats', () => {
|
||||
// Train 1, "Crack Limited" — 3 coaches, no freight or caboose (content.ts) — small enough to
|
||||
// exercise both a player count that wraps (2p: seats 0,1,0) and one that doesn't (3p: 0,1,2).
|
||||
for (const players of [2, 3]) {
|
||||
const s = game(players);
|
||||
s.clock.phase = 'newTrain';
|
||||
s.trays.set('t1', {
|
||||
id: 't1',
|
||||
trainNumber: 1,
|
||||
trainIsExtra: false,
|
||||
engineAt: 0,
|
||||
consist: [],
|
||||
direction: 'west',
|
||||
position: { at: 'divisionPoint', side: 'east' },
|
||||
movesUsed: 0,
|
||||
});
|
||||
s.yards.divisionYard.push(
|
||||
{ type: 'coach', loaded: false },
|
||||
{ type: 'coach', loaded: false },
|
||||
{ type: 'coach', loaded: false },
|
||||
);
|
||||
|
||||
const expected = [0, 1, 2].map((offset) => playerLeftOf(s, s.clock.superintendent, offset));
|
||||
const seenActors: PlayerIndex[] = [];
|
||||
for (let guard = 0; guard < 10; guard++) {
|
||||
const r = advance(s);
|
||||
if (!r.needsInput) break;
|
||||
const actor = s.clock.currentActor!;
|
||||
seenActors.push(actor);
|
||||
const result = applyIntent(s, actor, {
|
||||
type: 'newTrain.placeCar',
|
||||
trayId: 't1',
|
||||
carType: 'coach',
|
||||
loaded: false,
|
||||
});
|
||||
assert.ok(result.ok, `${players}p: placeCar rejected — ${result.ok ? '' : result.code}`);
|
||||
}
|
||||
|
||||
const wanted = [0, 1, 2].map((i) => expected[i % players]!);
|
||||
assert.deepEqual(
|
||||
seenActors,
|
||||
wanted,
|
||||
`${players}p: actor sequence was [${seenActors}], wanted [${wanted}] (Superintendent-then-left)`,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('actionMenu is seat-safe (Phase 2 prep)', () => {
|
||||
/**
|
||||
* REGRESSION. `actionMenu(game, seat)` used `seat` only for the `hand` field — `options`/`direct`/
|
||||
* `placeable`/`makeUp` all came from `currentActor(game)` regardless of which seat was asked. A
|
||||
* server computing every connected seat's Menu would have handed the acting player's legal moves to
|
||||
* a waiting seat, paired with the WRONG seat's hand. Found tracing Phase 2's per-seat Menu step.
|
||||
*/
|
||||
it('gives the acting seat its real options, and every other seat none at all', () => {
|
||||
const s = game(3);
|
||||
const g = wrap(s);
|
||||
const actorSeat = s.clock.currentActor!;
|
||||
const waiting = [0, 1, 2].filter((seat) => seat !== actorSeat);
|
||||
|
||||
const actorMenu = actionMenu(g, actorSeat as PlayerIndex);
|
||||
assert.ok(actorMenu.options.length > 0, "the acting seat's Menu had no options at all");
|
||||
|
||||
for (const seat of waiting) {
|
||||
const menu = actionMenu(g, seat as PlayerIndex);
|
||||
assert.deepEqual(menu.options, [], `seat ${seat} (not acting) was given real options`);
|
||||
assert.deepEqual(menu.direct, [], `seat ${seat} (not acting) was given direct actions`);
|
||||
assert.deepEqual(menu.placeable, [], `seat ${seat} (not acting) was given placeable actions`);
|
||||
assert.equal(menu.makeUp, null, `seat ${seat} (not acting) was given a make-up panel`);
|
||||
}
|
||||
});
|
||||
|
||||
it('still shows a waiting seat its own hand, just nothing to do with it', () => {
|
||||
const s = game(3);
|
||||
const g = wrap(s);
|
||||
const actorSeat = s.clock.currentActor!;
|
||||
const waitingSeat = [0, 1, 2].find((seat) => seat !== actorSeat)! as PlayerIndex;
|
||||
|
||||
const menu = actionMenu(g, waitingSeat);
|
||||
const expectedHand = s.decks.hands.get(waitingSeat) ?? [];
|
||||
assert.equal(menu.hand.length, expectedHand.length, "the waiting seat's own hand did not come through");
|
||||
for (const card of menu.hand) {
|
||||
assert.equal(card.playNow, null, 'a waiting seat was offered a way to play a card');
|
||||
assert.equal(card.spots, 0, 'a waiting seat was offered somewhere to place a card');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user