Files
ssessesse-ki-battle/src/client/match-controller.ts
T

109 lines
6.4 KiB
TypeScript

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<RecordAck> }; storage: SessionStorage;
clock?: GameClock; random?: () => number; onRematch?: () => Promise<void>;
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<typeof createFriendMatch> | 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<typeof createMatchController>;