import { MATCH_COUNTDOWN_MS, REVEAL_DURATION_MS, TIMEOUT_CHANCES } from '../../constants/gameplay.js'; import { DEFAULT_GLOBAL_SETTINGS } from '../../constants/global-settings.js'; import { matchPreparationDuration } from './match-introduction.js'; import { chooseBotAction } from './bot.js'; import { isLegalAction, resolveTurn } from './rules.js'; import { emptyPlayerStats, type LocalEndReason, type LocalMatchState, type MatchCheckpoint, type MatchConfig } from './p2p-types.js'; import type { ActionId, Pair, PlayerStats, TurnResolution } from './types.js'; export function createMatchEngine(config: MatchConfig) { let bot: ActionId | null = null; let state: LocalMatchState = { matchId: config.matchId, mode: config.mode, selfIndex: config.selfIndex, phase: 'countdown', timeouts: [0, 0], version: 0, energy: [0, 0], turnId: 0, clockNow: config.clock.now(), startsAt: Infinity, deadline: 0, ownAction: null, rematch: [false, false], opponentPresent: true, opponentLeft: false, saveStatus: 'pending', aborted: false, settings: { ...(config.settings ?? DEFAULT_GLOBAL_SETTINGS) }, }; function finish(winnerIndex: 0 | 1 | null, reason: LocalEndReason, aborted = false) { if (state.phase === 'finished') return; state.phase = 'finished'; state.aborted = aborted; state.version++; state.finished = { matchId: config.matchId, winnerIndex, reason, stats: emptyPlayerStats(), revealEndsAt: state.reveal?.revealEndsAt ?? config.clock.now(), saved: false }; } function finishTimeout(): boolean { const exhausted = (state.timeouts ?? [0, 0]).map(count => count >= TIMEOUT_CHANCES); if (!exhausted.some(Boolean)) return false; const winner = exhausted[0] && exhausted[1] ? null : exhausted[0] ? 1 : 0; finish(winner, winner === null ? 'double_timeout' : 'timeout'); return true; } function expireSelection(missing: Pair, deadline = state.deadline): boolean { if (state.phase !== 'selecting' || deadline !== state.deadline || config.clock.now() < deadline || !missing.some(Boolean)) return false; state.timeouts ??= [0, 0]; for (const slot of [0, 1] as const) if (missing[slot]) state.timeouts[slot]++; state.version++; if (finishTimeout()) return false; state.startsAt = deadline; state.deadline = deadline + (state.settings ?? DEFAULT_GLOBAL_SETTINGS).turnDurationMs; return true; } return { expireSelection, start(startsAt = config.clock.now()) { if (state.version !== 0) throw new Error('ALREADY_STARTED'); state.startsAt = startsAt + (config.mode === 'friend' ? matchPreparationDuration(state.settings) : MATCH_COUNTDOWN_MS); state.version++; }, beginTurn(startsAt = config.clock.now()): boolean { if (state.phase === 'selecting' || state.phase === 'finished') return false; const earliest = state.phase === 'countdown' ? state.startsAt : state.reveal!.revealEndsAt; if (startsAt < earliest || config.clock.now() < earliest) return false; state.phase = 'selecting'; state.turnId++; state.version++; state.startsAt = startsAt; state.deadline = startsAt + (state.settings ?? DEFAULT_GLOBAL_SETTINGS).turnDurationMs; state.ownAction = null; state.reveal = undefined; bot = config.mode === 'bot' ? chooseBotAction(state.energy[1], state.energy[0], config.random) : null; return true; }, lockAction(action: ActionId): boolean { if (state.phase !== 'selecting' || config.clock.now() < state.startsAt || config.clock.now() >= state.deadline || state.ownAction || !isLegalAction(action, state.energy[config.selfIndex])) return false; state.ownAction = action; state.version++; return true; }, botAction: () => bot, resolve(actions: Pair): TurnResolution { if (state.phase !== 'selecting') throw new Error('NOT_SELECTING'); if (config.clock.now() < state.startsAt || (actions.includes(null) && config.clock.now() < state.deadline)) throw new Error('BEFORE_DEADLINE'); if (actions.some((a, i) => a !== null && !isLegalAction(a, state.energy[i]))) throw new Error('INVALID_ACTION'); if (actions[0] === null || actions[1] === null) { expireSelection(actions.map(action => action === null) as Pair); return { energy: [...state.energy], winnerIndex: state.finished?.winnerIndex ?? null, hitIndex: null }; } const result = resolveTurn(state.energy, actions as Pair), now = config.clock.now(); state.reveal = { matchId: config.matchId, turnId: state.turnId, actions: [...actions] as Pair, energyBefore: [...state.energy], ...result, revealedAt: now, revealEndsAt: now + REVEAL_DURATION_MS }; state.energy = [...result.energy]; state.version++; if (result.winnerIndex !== null) finish(result.winnerIndex, 'hit'); else state.phase = 'revealing'; return structuredClone(result); }, finish, present(revealedAt: number) { if (!Number.isFinite(revealedAt)) throw new Error('INVALID_TIME'); if (state.reveal) { state.reveal.revealedAt = revealedAt; state.reveal.revealEndsAt = revealedAt + REVEAL_DURATION_MS; } if (state.finished) state.finished.revealEndsAt = state.reveal?.revealEndsAt ?? revealedAt; state.version++; }, presence(present: boolean) { state.opponentPresent = present; state.version++; }, requestRematch(slot: 0 | 1) { state.rematch[slot] = true; state.version++; }, resumeTurn(checkpoint: MatchCheckpoint, startsAt: number) { if (checkpoint.matchId !== config.matchId || checkpoint.state.selfIndex !== config.selfIndex) throw new Error('MATCH_MISMATCH'); if (checkpoint.state.phase === 'finished') throw new Error('ALREADY_FINISHED'); state = { ...structuredClone(checkpoint.state), version: Math.max(state.version, checkpoint.state.version) }; state.turnId = state.phase === 'revealing' ? state.turnId + 1 : Math.max(1, state.turnId); state.phase = 'selecting'; state.ownAction = null; state.reveal = undefined; if (finishTimeout()) return; state.startsAt = startsAt; state.deadline = startsAt + (state.settings ?? DEFAULT_GLOBAL_SETTINGS).turnDurationMs; state.opponentPresent = true; state.version++; }, abort(reason: 'connection_failed' | 'protocol_error') { finish(null, reason, true); }, saved(stats: PlayerStats) { state.saveStatus = 'saved'; if (state.finished) { state.finished.stats = structuredClone(stats); state.finished.saved = true; } state.version++; }, saveFailed() { state.saveStatus = 'failed'; state.version++; }, view(now = config.clock.now()): LocalMatchState { return structuredClone({ ...state, clockNow: now }); }, checkpoint(): MatchCheckpoint { return structuredClone({ matchId: config.matchId, state: { ...state, clockNow: config.clock.now() }, botAction: bot }); }, restore(checkpoint: MatchCheckpoint) { if (checkpoint.matchId !== config.matchId || checkpoint.state.matchId !== config.matchId || checkpoint.state.mode !== config.mode || checkpoint.state.selfIndex !== config.selfIndex) throw new Error('MATCH_MISMATCH'); state = { ...structuredClone(checkpoint.state), version: Math.max(state.version, checkpoint.state.version) + 1 }; bot = checkpoint.botAction; }, }; } export type MatchEngine = ReturnType;