/** * The game session host (`src/server/session.ts`) — pure logic, no sockets, exercised directly. */ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; import type { GameConfig, PlayerIndex } from '../../src/engine/state.ts'; import type { Push } from '../../src/server/session.ts'; import { createSession, resumeSession } from '../../src/server/session.ts'; const config: GameConfig = { mode: 'competitive', days: 5, minCombinedRevenue: 0, maxCollisionsPerDay: 0, maxCollisionsTotal: 0, pvpCardsAllowed: false, optionalRules: { reducedVisibility: false, sisterTrains: false, employeeRotation: false, emergencyToolbox: false, }, }; describe('the game session host', () => { it('gives a fresh connect a full Frame — nothing nulled', () => { const session = createSession(42, config, ['Alice', 'Bob']); const push = session.connect(0 as PlayerIndex); assert.notEqual(push.frame.cells, null, 'a first connect nulled the board'); assert.notEqual(push.frame.division, null, 'a first connect nulled the division'); }); it('only the current actor gets a real Menu; every other seat gets null', () => { const session = createSession(42, config, ['Alice', 'Bob']); const a = session.connect(0 as PlayerIndex); const b = session.connect(1 as PlayerIndex); const actorPushes = [a, b].filter((p) => p.menu !== null); assert.equal(actorPushes.length, 1, 'more than one seat (or zero) was given a real Menu'); }); it('rejects an intent from a seat that is not the current actor, with NOT_YOUR_TURN', () => { const session = createSession(42, config, ['Alice', 'Bob']); // Whichever seat is NOT the current actor should be refused, regardless of the intent's content. const bPush = session.connect(1 as PlayerIndex); const notActor = (bPush.menu === null ? 1 : 0) as PlayerIndex; const result = session.intent(notActor, 1, { type: 'localOps.choose', option: 'draw' }); assert.equal(result.accepted, false); if (!result.accepted) assert.equal(result.code, 'NOT_YOUR_TURN'); }); it('accepts a legal intent from the current actor and pushes every seat', () => { const session = createSession(42, config, ['Alice', 'Bob']); const aPush = session.connect(0 as PlayerIndex); const actor = (aPush.menu !== null ? 0 : 1) as PlayerIndex; const result = session.intent(actor, 1, { type: 'localOps.choose', option: 'draw' }); assert.equal(result.accepted, true); if (result.accepted) assert.equal(result.pushes.size, 2, 'not every connected seat was pushed to'); }); it('rejects an illegal intent with its real RejectionCode, and does not remember the seq', () => { const session = createSession(42, config, ['Alice', 'Bob']); const aPush = session.connect(0 as PlayerIndex); const actor = (aPush.menu !== null ? 0 : 1) as PlayerIndex; // Discarding before ever drawing is illegal on an opening hand at the limit — a safe "this is // definitely rejected" fixture that does not depend on exactly which cards were dealt. const bad = session.intent(actor, 1, { type: 'card.discard', cardId: 'not-a-real-card', toSlot: 0 }); assert.equal(bad.accepted, false); if (!bad.accepted) assert.ok(bad.code.length > 0, 'a rejection carried no code at all'); // The same seq, now with a legal intent, must still go through — a rejection is not "applied". const retry = session.intent(actor, 1, { type: 'localOps.choose', option: 'draw' }); assert.equal(retry.accepted, true, 'a seq burned by an earlier rejection could not be reused'); }); it('treats a repeated seq as an already-applied no-op, not a second application', () => { const session = createSession(42, config, ['Alice', 'Bob']); const aPush = session.connect(0 as PlayerIndex); const actor = (aPush.menu !== null ? 0 : 1) as PlayerIndex; const first = session.intent(actor, 1, { type: 'localOps.choose', option: 'draw' }); assert.equal(first.accepted, true); const second = session.intent(actor, 1, { type: 'localOps.choose', option: 'draw' }); assert.equal(second.accepted, true, 'a resend of an already-applied seq was rejected instead of ignored'); if (second.accepted) assert.equal(second.pushes.size, 0, 'a resend produced pushes as if newly applied'); }); it('deltas the board on a second push when it has not changed since the first', () => { const session = createSession(42, config, ['Alice', 'Bob']); const aPush = session.connect(0 as PlayerIndex); session.connect(1 as PlayerIndex); // both seats need a baseline Frame before a delta means anything const actor = (aPush.menu !== null ? 0 : 1) as PlayerIndex; const result = session.intent(actor, 1, { type: 'localOps.choose', option: 'draw' }); assert.equal(result.accepted, true); if (!result.accepted) return; // Choosing "draw" doesn't move a single card on the board — the division/cells should be nulled // on this push for a seat that already had them from `connect`. const push = result.pushes.get(actor)!; assert.equal(push.frame.division, null, 'the board was resent even though nothing on it changed'); }); it('only sends narration NEW since the last push to that specific seat', () => { const session = createSession(42, config, ['Alice', 'Bob']); const aPush = session.connect(0 as PlayerIndex); const actor = (aPush.menu !== null ? 0 : 1) as PlayerIndex; // A first connect is this seat's first contact, so it gets everything narrated so far (the // "Game Begins" intro and whatever `drain()` said entering the first Stage) — not an empty log. assert.ok(aPush.lines.length > 0, 'a first connect got no narration at all, not even the game-begins intro'); const first = session.intent(actor, 1, { type: 'localOps.choose', option: 'draw' }); assert.equal(first.accepted, true); if (!first.accepted) return; const firstLines = first.pushes.get(actor)!.lines; assert.ok(firstLines.length > 0, 'the acting seat got no narration for its own action'); const second = session.intent(actor, 2, { type: 'draw.end' }); assert.equal(second.accepted, true); if (!second.accepted) return; const secondLines = second.pushes.get(actor)!.lines; assert.ok(secondLines.length > 0, 'a second real action produced no narration at all'); for (const line of firstLines) { assert.ok(!secondLines.includes(line), 'the second push repeated narration already sent in the first'); } }); }); describe('turn timings (lobby-and-sessions.md §5)', () => { it('records a wall-clock span once the acting player, phase, Day or Stage changes', () => { const session = createSession(42, config, ['Alice', 'Bob']); let pushes = new Map([ [0 as PlayerIndex, session.connect(0 as PlayerIndex)], [1 as PlayerIndex, session.connect(1 as PlayerIndex)], ]); const seq = new Map([[0 as PlayerIndex, 1], [1 as PlayerIndex, 1]]); const timings = []; for (let step = 0; step < 60 && timings.length === 0; step++) { const acting = [0, 1].find((seat) => pushes.get(seat as PlayerIndex)!.menu !== null) as PlayerIndex | undefined; const menu = acting === undefined ? null : pushes.get(acting)!.menu; if (acting === undefined || !menu || menu.options.length === 0) break; const n = seq.get(acting)!; seq.set(acting, n + 1); const result = session.intent(acting, n, menu.options[0]!); assert.equal(result.accepted, true, `step ${step}: ${JSON.stringify(menu.options[0])} rejected`); if (!result.accepted) break; if (result.timing) timings.push(result.timing); pushes = result.pushes; } assert.ok(timings.length > 0, 'no turn timing ever closed across 60 real steps of actual play'); const [timing] = timings; assert.ok(timing!.endedAt >= timing!.startedAt, 'a span ended before it started'); assert.ok([0, 1].includes(timing!.player), 'a timing named a player outside the table'); }); }); describe('persistence hooks — exportSave / resumeSession (Phase 3)', () => { it('exportSave carries enough to reconstruct the exact same game', () => { const session = createSession(42, config, ['Alice', 'Bob']); const push = session.connect(0 as PlayerIndex); const actor = (push.menu !== null ? 0 : 1) as PlayerIndex; const applied = session.intent(actor, 1, { type: 'localOps.choose', option: 'draw' }); assert.equal(applied.accepted, true); const saved = session.exportSave(); assert.equal(saved.seed, 42); assert.deepEqual(saved.playerNames, ['Alice', 'Bob']); assert.equal(saved.history.length, 1); assert.equal(saved.status, 'active'); assert.ok(saved.createdAt > 0, 'createdAt was not set'); const resumed = resumeSession(saved); const before = session.connect(actor); const after = resumed.connect(actor); assert.deepEqual(after.frame, before.frame, 'resumeSession did not reconstruct the same board/state'); }); it('a resumed session keeps enforcing whose turn it is', () => { const session = createSession(7, config, ['Alice', 'Bob']); const push = session.connect(0 as PlayerIndex); const actor = (push.menu !== null ? 0 : 1) as PlayerIndex; session.intent(actor, 1, { type: 'localOps.choose', option: 'draw' }); const resumed = resumeSession(session.exportSave()); const notActor = (actor === 0 ? 1 : 0) as PlayerIndex; const rejected = resumed.intent(notActor, 1, { type: 'draw.end' }); assert.equal(rejected.accepted, false); if (!rejected.accepted) assert.equal(rejected.code, 'NOT_YOUR_TURN'); }); it('marks status finished only once the game actually is', () => { const session = createSession(42, config, ['Alice', 'Bob']); assert.equal(session.exportSave().status, 'active'); }); });