import { test } from 'node:test'; import assert from 'node:assert/strict'; import { createMatchController } from '../src/client/match-controller.js'; import { emptyPlayerStats, type PeerMessage, type ResultSubmission } from '../src/shared/p2p-types.js'; import { ManualClock } from './helpers.js'; import type { PeerState } from '../src/client/peer.js'; test('failed rematch request remains retryable', async () => { let attempts = 0; const h = setup(async () => { if (++attempts === 1) throw new Error('offline'); }); const [a, b] = h.controllers; try { await Promise.all(h.controllers.map(c => c.start())); await a.leave(); await h.clock.advance(1); assert.equal(a.view().finished?.reason, 'forfeit'); assert.equal(b.view().finished?.winnerIndex, 1); await assert.rejects(a.rematch(), /offline/); assert.equal(a.view().rematch[0], false); await a.rematch(); assert.equal(attempts, 2); } finally { h.close(); } }); class Wire { epoch = 0; connected = false; remote?: Wire; drop = false; intercept?: (message: PeerMessage) => boolean; messages = new Set<(m: PeerMessage) => void>(); states = new Set<(s: PeerState) => void>(); async connect() { this.connected = true; } send(message: PeerMessage) { if (!this.connected) throw new Error('DISCONNECTED'); if (this.intercept?.(message)) return; if (!this.drop) queueMicrotask(() => { if (this.remote?.connected) for (const h of this.remote.messages) h(structuredClone(message)); }); } onMessage(h: (m: PeerMessage) => void) { this.messages.add(h); return () => { this.messages.delete(h); }; } onState(h: (s: PeerState) => void) { this.states.add(h); return () => { this.states.delete(h); }; } async restart(epoch = this.epoch + 1) { this.epoch = epoch; this.connected = true; } close() { this.connected = false; } disconnect() { this.connected = false; for (const h of this.states) h('disconnected'); } } test('peer emotes reach the correct slot without exposing choices or changing the turn', async () => { const h = setup(), [a, b] = h.controllers; try { await Promise.all(h.controllers.map(c => c.start())); await h.clock.advance(4000); await a.submitAction('charge'); await h.clock.advance(1); const before = b.view(); assert.equal(a.sendEmote('greet'), true); assert.equal(a.sendEmote('taunt'), false); await h.clock.advance(1); assert.equal(b.view().emotes?.[0]?.id, 'greet'); assert.equal(b.view().ownAction, null); assert.equal(b.view().turnId, before.turnId); assert.deepEqual(b.view().energy, before.energy); assert.equal(b.view().phase, 'selecting'); await b.leave(); assert.equal(b.sendEmote('threaten'), false); } finally { h.close(); } }); function setup(onRematch?: () => Promise, guestClockOffset = 0, guestStartDelay = 0) { const clock = new ManualClock(); clock.time = 1000; const a = new Wire(), b = new Wire(); a.remote = b; b.remote = a; const records: ResultSubmission[][] = [[], []]; const storages: any[] = []; const controllers = [a, b].map((peer, slot) => { const memory = new Map(); const storage = { getItem: (k: string) => memory.get(k) ?? null, setItem: (k: string, v: string) => { memory.set(k, v); }, removeItem: (k: string) => { memory.delete(k); } }; storages.push(storage); const localClock = slot === 1 ? { now: () => clock.now() + guestClockOffset, setTimeout: (fn: () => void, ms: number) => clock.setTimeout(fn, ms + (ms === 4000 || ms === 0 ? guestStartDelay : 0)), clearTimeout: clock.clearTimeout } : clock; return createMatchController({ activeMatch: { matchId: 'friend-match', seq: 1, mode: 'friend', roomId: 'r' }, gameSessionId: `session-${slot}`, selfIndex: slot as 0 | 1, clock: localClock, peer, onRematch, reporter: { submit: async data => { records[slot].push(data); return { status: 'applied', stats: emptyPlayerStats() }; } }, storage, }); }); return { clock, a, b, controllers, records, storages, close: () => controllers.forEach(c => c.destroy()) }; } test('two peers agree turns and record complementary results without server turn events', async () => { const h = setup(); const [a, b] = h.controllers; try { await Promise.all(h.controllers.map(c => c.start())); await h.clock.advance(4000); assert.equal(a.view().phase, 'selecting'); assert.equal(b.view().phase, 'selecting'); await a.submitAction('charge'); await h.clock.advance(1); assert.equal(a.view().phase, 'selecting'); await b.submitAction('charge'); await h.clock.advance(1); assert.equal(a.view().phase, 'revealing'); assert.equal(b.view().phase, 'revealing'); assert.deepEqual(a.view().energy, [1, 1]); assert.deepEqual(b.view().energy, [1, 1]); await h.clock.advance(1200); await a.submitAction('blast'); await b.submitAction('charge'); await h.clock.advance(4000); assert.equal(h.records[0][0]?.outcome, 'win'); assert.equal(h.records[1][0]?.outcome, 'loss'); assert.equal(h.records[0].length, 1); assert.equal(h.records[1].length, 1); } finally { h.close(); } }); test('an immediate choice survives the other browser starting the announced turn late', async () => { const h = setup(undefined, 0, 100), [a, b] = h.controllers; try { await Promise.all(h.controllers.map(c => c.start())); await h.clock.advance(0); await h.clock.advance(4000); assert.equal(a.view().phase, 'selecting'); assert.equal(b.view().phase, 'countdown'); await a.submitAction('charge'); await h.clock.advance(0); await h.clock.advance(100); await b.submitAction('charge'); await h.clock.advance(1); assert.equal(a.view().phase, 'revealing'); assert.equal(b.view().phase, 'revealing'); assert.deepEqual(a.view().energy, [1, 1]); assert.deepEqual(b.view().energy, [1, 1]); await h.clock.advance(1200); assert.equal(a.view().phase, 'selecting'); assert.equal(b.view().phase, 'revealing'); await a.submitAction('blast'); await h.clock.advance(0); await h.clock.advance(100); await b.submitAction('charge'); await h.clock.advance(1); assert.equal(a.view().finished?.reason, 'hit'); assert.equal(b.view().finished?.reason, 'hit'); } finally { h.close(); } }); test('loss of final presentation after digest acknowledgements preserves both agreed results', async () => { const h = setup(); const [a, b] = h.controllers; try { await Promise.all(h.controllers.map(c => c.start())); await h.clock.advance(4000); await a.submitAction('charge'); await b.submitAction('charge'); await h.clock.advance(1); await h.clock.advance(1200); await a.submitAction('blast'); await b.submitAction('charge'); h.a.intercept = m => { if (m.type !== 'turn:present') return false; queueMicrotask(() => { h.a.drop = h.b.drop = true; h.a.disconnect(); h.b.disconnect(); }); return true; }; await h.clock.advance(14000); assert.deepEqual(h.records.map(r => r.map(x => x.outcome)), [['win'], ['loss']]); } finally { h.close(); } }); for (const leavingSlot of [0, 1] as const) test(`leaving a finished match as slot ${leavingSlot} never restarts the other player's final reveal`, async () => { const h = setup(), [a, b] = h.controllers; const remainingSlot = leavingSlot === 0 ? 1 : 0; try { await Promise.all(h.controllers.map(c => c.start())); await h.clock.advance(4000); await a.submitAction('charge'); await b.submitAction('charge'); await h.clock.advance(1); await h.clock.advance(1200); await a.submitAction('blast'); await b.submitAction('charge'); await h.clock.advance(4000); const remaining = h.controllers[remainingSlot], before = remaining.view(); assert.equal(before.finished?.reason, 'hit'); assert.equal(before.finished?.saved, true); assert.ok(before.reveal!.revealEndsAt < h.clock.now()); h.controllers[leavingSlot].destroy(); const wire = remainingSlot === 0 ? h.a : h.b; wire.disconnect(); await h.clock.advance(1); assert.equal(remaining.view().opponentPresent, false); assert.deepEqual(remaining.view().reveal, before.reveal); assert.deepEqual(remaining.view().finished, before.finished); wire.disconnect(); await h.clock.advance(1); assert.deepEqual(remaining.view().reveal, before.reveal); assert.deepEqual(h.records.map(records => records.length), [1, 1]); } finally { h.close(); } }); test('crossed explicit leave requests produce one draw per player instead of two wins', async () => { const h = setup(); try { await Promise.all(h.controllers.map(c => c.start())); await Promise.all(h.controllers.map(c => c.leave())); await h.clock.advance(1); assert.deepEqual(h.records.map(r => r.map(x => x.outcome)), [['draw'], ['draw']]); } finally { h.close(); } }); test('recovery starts a fresh epoch and ignores messages from the previous connection', async () => { const h = setup(); const [a, b] = h.controllers; try { await Promise.all(h.controllers.map(c => c.start())); await h.clock.advance(4000); await a.submitAction('charge'); h.a.disconnect(); h.b.disconnect(); await h.clock.advance(100); assert.equal(h.a.epoch, 1); assert.equal(h.b.epoch, 1); assert.equal(a.view().ownAction, null); assert.equal(b.view().ownAction, null); assert.equal(h.records[0].length, 0); h.a.send({ protocolVersion: 1, rulesVersion: '1', matchId: 'friend-match', connectionEpoch: 0, turnId: 1, type: 'turn:commit', digest: '0'.repeat(64), slot: 0 }); await h.clock.advance(1); assert.equal(b.view().phase, 'selecting'); } finally { h.close(); } }); test('peers whose wall clocks differ by five seconds agree on deadline and timeout result', async () => { const h = setup(undefined, 5000); const [a, b] = h.controllers; try { await Promise.all(h.controllers.map(c => c.start())); await h.clock.advance(4000); assert.equal(a.view().deadline, b.view().deadline); assert.equal(a.view().clockNow, b.view().clockNow); await a.submitAction('charge'); await h.clock.advance(4000); assert.equal(h.records[0][0]?.outcome, 'win'); assert.equal(h.records[1][0]?.outcome, 'loss'); assert.equal(a.view().finished?.reason, 'timeout'); assert.equal(b.view().finished?.reason, 'timeout'); } finally { h.close(); } }); test('unrecoverable transport interruption aborts rather than inventing two wins', async () => { const h = setup(); try { await Promise.all(h.controllers.map(c => c.start())); await h.clock.advance(4000); h.a.drop = h.b.drop = true; h.a.disconnect(); h.b.disconnect(); await new Promise(r => setImmediate(r)); // drain already-delivered messages before advancing the recovery clock await h.clock.advance(10000); assert.equal(h.records[0][0]?.outcome, 'aborted'); assert.equal(h.records[1][0]?.outcome, 'aborted'); } finally { h.close(); } }); test('refresh of a mutually confirmed final result saves without reconnecting to the opponent', async () => { const h = setup(); try { await Promise.all(h.controllers.map(c => c.start())); await h.clock.advance(7000); const { GAME_CHECKPOINT_KEY } = await import('../constants/recording.js'); const saved = JSON.parse(h.storages[0].getItem(GAME_CHECKPOINT_KEY)); saved.checkpoint.state.finished.saved = false; saved.checkpoint.state.saveStatus = 'pending'; h.storages[0].setItem(GAME_CHECKPOINT_KEY, JSON.stringify(saved)); h.close(); let recorded = 0; const restored = createMatchController({ activeMatch: saved.activeMatch, gameSessionId: 'session-0', selfIndex: 0, clock: h.clock, storage: h.storages[0], reporter: { submit: async data => { recorded++; assert.equal(data.outcome, 'draw'); return { status: 'duplicate', stats: emptyPlayerStats() }; } } }); try { await restored.start(); assert.equal(recorded, 1); assert.equal(restored.view().saveStatus, 'saved'); } finally { restored.destroy(); } } finally { h.close(); } }); test('silent packet loss near a deadline enters recovery before declaring a protocol error', async () => { const h = setup(); try { await Promise.all(h.controllers.map(c => c.start())); await h.clock.advance(4000); await Promise.all(h.controllers.map(c => c.submitAction('charge'))); await h.clock.advance(1); await h.clock.advance(1200); // Last heartbeat was just delivered; no RTC state event arrives when packets stop. h.a.drop = h.b.drop = true; await h.clock.advance(17000); assert.ok(h.a.epoch > 0 && h.b.epoch > 0); assert.deepEqual(h.records.map(r => r.map(x => [x.outcome, x.reason])), [[['aborted', 'connection_failed']], [['aborted', 'connection_failed']]]); } finally { h.close(); } }); test('transport send failure at selection deadline attempts recovery', async () => { const h = setup(); try { await Promise.all(h.controllers.map(c => c.start())); await h.clock.advance(4000); await h.clock.advance(2900); h.a.connected = h.b.connected = false; h.a.restart = h.b.restart = async () => { throw new Error('offline'); }; await h.clock.advance(100); assert.deepEqual(h.records.map(r => r.map(x => x.reason)), [['connection_failed'], ['connection_failed']]); } finally { h.close(); } }); test('recovery blocks actions, ignores repeated disconnects and records interruption only once', async () => { const h = setup(); try { await Promise.all(h.controllers.map(c => c.start())); await h.clock.advance(4000); h.a.drop = h.b.drop = true; h.a.disconnect(); h.b.disconnect(); await h.clock.advance(1); assert.equal(await h.controllers[0].submitAction('charge'), false); h.a.disconnect(); h.b.disconnect(); await h.clock.advance(1); assert.equal(h.a.epoch, 1); assert.equal(h.b.epoch, 1); await h.clock.advance(10000); assert.deepEqual(h.records.map(r => r.length), [1, 1]); assert.deepEqual(h.records.map(r => r[0].outcome), ['aborted', 'aborted']); } finally { h.close(); } }); test('leaving during unavailable recovery settles and records once', async () => { const h = setup(); try { await Promise.all(h.controllers.map(c => c.start())); await h.clock.advance(4000); h.a.drop = h.b.drop = true; h.a.disconnect(); h.b.disconnect(); await h.clock.advance(1); const leaving = h.controllers[0].leave(); await h.clock.advance(3000); await leaving; await h.clock.advance(10000); assert.deepEqual(h.records.map(r => r.map(x => x.outcome)), [['aborted'], ['aborted']]); } finally { h.close(); } }); test('loss of result agreement cannot display or save a speculative winner during recovery', async () => { const h = setup(); const [a, b] = h.controllers; try { await Promise.all(h.controllers.map(c => c.start())); await h.clock.advance(4000); await a.submitAction('charge'); await b.submitAction('charge'); await h.clock.advance(1); await h.clock.advance(1200); await a.submitAction('blast'); await b.submitAction('charge'); h.a.intercept = h.b.intercept = message => message.type === 'turn:resolved'; h.a.restart = h.b.restart = () => new Promise(() => {}); await h.clock.advance(7000); assert.deepEqual(h.controllers.map(c => c.view().finished), [undefined, undefined]); assert.deepEqual(h.controllers.map(c => c.view().opponentPresent), [false, false]); await Promise.all(h.controllers.map(c => c.retrySave())); assert.deepEqual(h.records.map(r => r.length), [0, 0]); await h.clock.advance(10000); assert.deepEqual(h.records.map(r => r.map(x => x.outcome)), [['aborted'], ['aborted']]); } finally { h.close(); } });