Initial commit: ssessesse ki battle

This commit is contained in:
2026-10-13 11:15:26 +09:00
commit 4f24129e29
104 changed files with 10520 additions and 0 deletions
+90
View File
@@ -0,0 +1,90 @@
import { MATCH_COUNTDOWN_MS, REVEAL_DURATION_MS, TURN_DURATION_MS } from '../../constants/gameplay.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',
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,
};
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 };
}
return {
start(startsAt = config.clock.now()) {
if (state.version !== 0) throw new Error('ALREADY_STARTED');
state.startsAt = startsAt + 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 + TURN_DURATION_MS;
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<ActionId | null>): TurnResolution {
if (state.phase !== 'selecting') throw new Error('NOT_SELECTING');
if (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) {
const winner = actions[0] === null && actions[1] === null ? null : actions[0] === null ? 1 : 0;
finish(winner, winner === null ? 'double_timeout' : 'timeout');
return { energy: [...state.energy], winnerIndex: winner, hitIndex: null };
}
const result = resolveTurn(state.energy, actions as Pair<ActionId>), now = config.clock.now();
state.reveal = { matchId: config.matchId, turnId: state.turnId, actions: [...actions] as Pair<ActionId>,
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;
state.startsAt = startsAt; state.deadline = startsAt + TURN_DURATION_MS; 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<typeof createMatchEngine>;