import { GAME_CHECKPOINT_KEY } from '../../constants/recording.js'; import { createMatchEngine } from '../shared/match-engine.js'; import type { ActiveMatch, LocalMatchState, MatchCheckpoint, RecordAck, ResultSubmission } from '../shared/p2p-types.js'; import type { ActionId } from '../shared/types.js'; import type { SessionStorage } from './result-reporter.js'; import { createFriendMatch, type GamePeer } from './friend-match.js'; import { createEmotes } from '../shared/emotes.js'; import type { EmoteId } from '../../constants/emotes.js'; export interface GameClock { now(): number; setTimeout(fn: () => void, ms: number): any; clearTimeout(id: any): void } export interface ControllerConfig { activeMatch: ActiveMatch; gameSessionId: string; selfIndex: 0 | 1; reporter: { submit(data: ResultSubmission): Promise }; storage: SessionStorage; clock?: GameClock; random?: () => number; onRematch?: () => Promise; peer?: GamePeer; } export interface ControllerCheckpoint { gameSessionId: string; activeMatch: ActiveMatch; checkpoint: MatchCheckpoint; epoch: number; confirmed?: MatchCheckpoint } const defaultClock: GameClock = { now: Date.now, setTimeout: (fn, ms) => setTimeout(fn, ms), clearTimeout: id => clearTimeout(id) }; export function createMatchController(config: ControllerConfig) { const clock = config.clock ?? defaultClock; let offset = 0; const engine = createMatchEngine({ ...config.activeMatch, selfIndex: config.selfIndex, clock: { now: () => clock.now() + offset }, random: config.random }); let friend: ReturnType | undefined; const listeners = new Set<(state: LocalMatchState) => void>(); const emotes = createEmotes(); const view = () => ({ ...engine.view(), appearances: config.activeMatch.appearances, emotes: emotes.view(clock.now() + offset) }); let timer: any, destroyed = false, started = false, recording = false; function publish() { if (destroyed) return; const checkpoint: ControllerCheckpoint = { gameSessionId: config.gameSessionId, activeMatch: config.activeMatch, checkpoint: engine.checkpoint(), epoch: 0, ...friend?.snapshot() }; config.storage.setItem(GAME_CHECKPOINT_KEY, JSON.stringify(checkpoint)); for (const listener of listeners) listener(view()); } async function record() { const state = engine.view(); if (!state.finished || state.finished.saved || recording || destroyed) return; recording = true; const outcome = state.aborted ? 'aborted' : state.finished.winnerIndex === null ? 'draw' : state.finished.winnerIndex === config.selfIndex ? 'win' : 'loss'; try { const ack = await config.reporter.submit({ ...config.activeMatch, outcome, reason: state.finished.reason }); if (!destroyed) { engine.saved(ack.stats); publish(); } } catch { if (!destroyed) { engine.saveFailed(); publish(); } } finally { recording = false; } } function tick() { if (destroyed) return; clock.clearTimeout(timer); let state = engine.view(); if (state.phase === 'countdown' && clock.now() >= state.startsAt) engine.beginTurn(state.startsAt); else if (state.phase === 'revealing' && clock.now() >= state.reveal!.revealEndsAt) engine.beginTurn(state.reveal!.revealEndsAt); state = engine.view(); if (state.phase === 'selecting' && (state.ownAction || clock.now() >= state.deadline)) engine.resolve([state.ownAction, engine.botAction()]); publish(); state = engine.view(); if (state.phase === 'finished') { void record(); return; } const next = state.phase === 'countdown' ? state.startsAt : state.phase === 'selecting' ? state.deadline : state.reveal!.revealEndsAt; timer = clock.setTimeout(tick, Math.max(0, next - clock.now())); } return { async start() { if (started) return; started = true; let saved: ControllerCheckpoint | undefined; try { saved = JSON.parse(config.storage.getItem(GAME_CHECKPOINT_KEY) ?? 'null') ?? undefined; } catch { /* new game */ } if (saved?.gameSessionId !== config.gameSessionId || saved.activeMatch.matchId !== config.activeMatch.matchId) saved = undefined; if (saved) engine.restore(saved.checkpoint); if (saved?.checkpoint.state.phase === 'finished' && (config.activeMatch.mode === 'bot' || saved.confirmed?.state.phase === 'finished' || saved.checkpoint.state.aborted)) { publish(); await record(); return; } if (config.activeMatch.mode === 'friend') { if (!config.peer) throw new Error('PEER_REQUIRED'); friend = createFriendMatch({ matchId: config.activeMatch.matchId, slot: config.selfIndex, engine, peer: config.peer, clock, setOffset: value => { offset = value; }, changed: publish, finished: () => { void record(); }, saved, onEmote: (id, variant) => { if (emotes.send(config.selfIndex === 0 ? 1 : 0, id, clock.now() + offset, variant)) publish(); } }); await friend.start(); return; } if (!saved) engine.start(); tick(); }, async submitAction(action: ActionId) { if (friend?.recovering) return false; const accepted = engine.lockAction(action); if (accepted) { if (friend) { publish(); friend.submitAction(); } else tick(); } return accepted; }, async leave() { if (friend) { await friend.leave(); await record(); return; } clock.clearTimeout(timer); if (engine.view().phase !== 'finished') engine.finish(config.selfIndex === 0 ? 1 : 0, 'forfeit'); publish(); await record(); }, async rematch() { if (!engine.view().finished?.saved) return; await config.onRematch?.(); if (!destroyed) friend?.requestRematch(); }, sendEmote(id: EmoteId) { if (destroyed || !started || engine.view().phase === 'finished' || friend?.recovering) return false; const now = clock.now() + offset; if (config.activeMatch.mode === 'friend' && !config.peer?.connected) return false; if (!emotes.send(config.selfIndex, id, now)) return false; if (friend && !friend.sendEmote(id, emotes.view(now)[config.selfIndex]!.variant)) return false; publish(); return true; }, view, subscribe(listener: (state: LocalMatchState) => void) { listeners.add(listener); listener(view()); return () => { listeners.delete(listener); }; }, retrySave: record, destroy() { destroyed = true; clock.clearTimeout(timer); friend?.destroy(); listeners.clear(); }, }; } export type MatchController = ReturnType;