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:
@@ -19,8 +19,11 @@ import { trainRules } from '../src/sim/view.ts';
|
||||
|
||||
const config: GameConfig = {
|
||||
mode: 'solitaire',
|
||||
victory: 'highestAfterDays',
|
||||
length: 'standard',
|
||||
days: 5,
|
||||
minCombinedRevenue: 0,
|
||||
maxCollisionsPerDay: 0,
|
||||
maxCollisionsTotal: 0,
|
||||
pvpCardsAllowed: false,
|
||||
optionalRules: { reducedVisibility: false, sisterTrains: false, employeeRotation: false, emergencyToolbox: false },
|
||||
};
|
||||
const game = (seed = 5): GameState => createGame({ id: 'g', seed, config, playerNames: ['p'] });
|
||||
@@ -46,9 +49,9 @@ function switching(
|
||||
): string {
|
||||
const area = areaOf(s, 0);
|
||||
const office = area.officeCoord;
|
||||
// The crew stands one square west of the Office and works westward. It may NOT stand on the Office
|
||||
// itself: §A.4 forbids leaving Rolling Stock on the Office track, so every drop there is refused
|
||||
// whatever the train's own card says.
|
||||
// The crew stands one square west of the Office and works westward, away from the Office square's
|
||||
// own special-cased drop rule (§A.4 forbids everything but a coach there, v0.5.0) — tests that want
|
||||
// that exception exercise it directly instead.
|
||||
const here = { row: office.row, col: office.col - 1 };
|
||||
area.grid.set(coordKey(here), straight());
|
||||
area.grid.set(coordKey({ row: office.row, col: office.col - 2 }), straight(westCars));
|
||||
@@ -202,6 +205,41 @@ describe('§7 — the Local keeps its coach (trains 7/8)', () => {
|
||||
switching(s, 9, false, [boxcar(), coach()]);
|
||||
assert.equal(check(s, 0, { type: 'switch.dropCars', trayId: 't', count: 1 }), null);
|
||||
});
|
||||
|
||||
it('may set the coach out at the Office — the one place §A.4 now allows it (v0.5.0)', () => {
|
||||
const s = game();
|
||||
const area = areaOf(s, 0);
|
||||
const office = area.officeCoord;
|
||||
s.trays.set('t', {
|
||||
id: 't',
|
||||
trainNumber: 7,
|
||||
trainIsExtra: false,
|
||||
engineAt: 0,
|
||||
consist: [boxcar(), coach()],
|
||||
direction: 'west',
|
||||
facing: 'w',
|
||||
position: { at: 'grid', seat: 0, coord: office },
|
||||
movesUsed: 0,
|
||||
});
|
||||
s.clock.phase = 'localOps';
|
||||
s.clock.currentActor = 0;
|
||||
turnOf(s, 0).option = 'switch';
|
||||
turnOf(s, 0).movesRemaining = 6;
|
||||
|
||||
assert.equal(
|
||||
check(s, 0, { type: 'switch.dropCars', trayId: 't', count: 1 }),
|
||||
null,
|
||||
'the coach may be cut loose at the Office',
|
||||
);
|
||||
assert.ok(applyIntent(s, 0, { type: 'switch.dropCars', trayId: 't', count: 1 }).ok);
|
||||
const officeCard = area.grid.get(coordKey(office))!;
|
||||
assert.deepEqual(
|
||||
officeCard.standing.map((c) => c.type),
|
||||
['coach'],
|
||||
'the coach is parked on the Office card',
|
||||
);
|
||||
assert.equal(s.trays.get('t')!.consist.length, 1, 'the coach left the tray');
|
||||
});
|
||||
});
|
||||
|
||||
describe('§7 — trains Porters may not work', () => {
|
||||
|
||||
Reference in New Issue
Block a user