feat: allow three cumulative selection timeouts per match

This commit is contained in:
2026-10-15 11:51:08 +09:00
parent fc5e475f24
commit 293ed7bcc4
14 changed files with 228 additions and 37 deletions
+23 -5
View File
@@ -1,4 +1,4 @@
import { MATCH_COUNTDOWN_MS, REVEAL_DURATION_MS } from '../../constants/gameplay.js';
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';
@@ -10,7 +10,7 @@ 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,
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) },
};
@@ -20,7 +20,25 @@ export function createMatchEngine(config: MatchConfig) {
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<boolean>, 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++;
@@ -45,9 +63,8 @@ export function createMatchEngine(config: MatchConfig) {
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) {
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 };
expireSelection(actions.map(action => action === null) as Pair<boolean>);
return { energy: [...state.energy], winnerIndex: state.finished?.winnerIndex ?? null, 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>,
@@ -72,6 +89,7 @@ export function createMatchEngine(config: MatchConfig) {
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); },