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
+2 -1
View File
@@ -11,6 +11,7 @@ export const ACTION_IDS = Object.keys(ACTIONS) as (keyof typeof ACTIONS)[];
export const MATCH_COUNTDOWN_MS = 3000;
export const TURN_DURATION_MS = DEFAULT_GLOBAL_SETTINGS.turnDurationMs;
export const REVEAL_DURATION_MS = 1200;
export const RULES_VERSION = '2';
export const TIMEOUT_CHANCES = 3;
export const RULES_VERSION = '3';
export const BOT_VERSION = '1';
export const APP_VERSION = '0.1.0';
+60 -10
View File
@@ -1,4 +1,4 @@
import { RULES_VERSION } from '../../constants/gameplay.js';
import { RULES_VERSION, TIMEOUT_CHANCES } from '../../constants/gameplay.js';
import { RECONNECT_GRACE_MS } from '../../constants/session.js';
import { PEER_PING_MS, PEER_SILENCE_MS, PROTOCOL_TIMEOUT_MS, PROTOCOL_VERSION, RESUME_START_DELAY_MS } from '../../constants/webrtc.js';
import type { MatchEngine } from '../shared/match-engine.js';
@@ -12,7 +12,7 @@ import { matchIntroductionDuration, matchPreparationDuration } from '../shared/m
export type GamePeer = Pick<PeerConnection, 'connect' | 'connected' | 'epoch' | 'send' | 'onMessage' | 'onState' | 'restart' | 'close'>;
interface FriendConfig {
matchId: string; slot: 0 | 1; engine: MatchEngine; peer: GamePeer; clock: GameClock;
setOffset(offset: number): void; changed(): void; finished(): void;
setOffset(offset: number): void; changed(): void; checkpointChanged(): void; finished(): void;
saved?: { confirmed?: MatchCheckpoint; epoch: number };
onEmote?(id: unknown, variant: unknown): void;
}
@@ -21,6 +21,7 @@ export function createFriendMatch(config: FriendConfig) {
let stopped = false, recovering = false, offset = 0, localReady = false, peerReady = false, begun = false;
let confirmed = config.saved?.confirmed ?? engine.checkpoint();
let exchange: TurnExchange | undefined, ownDigest = '', remoteDigest = '', remoteAck = '', sentAck = false, presented = false;
let timeoutReports: Pair<boolean | null> = [null, null];
let pendingTurnId: number | undefined, pendingCommit: Extract<PeerMessage, { type: 'turn:commit' }> | undefined;
let presentAt: number | undefined, privateChoice: { action: ActionId | null; nonce: string } | undefined;
let resumedPeer = false, resumedAck = false, lastPeerAt = clock.now(), queue = Promise.resolve();
@@ -53,7 +54,7 @@ export function createFriendMatch(config: FriendConfig) {
function armProtocol() { later('protocol', () => { void recover(); }, PROTOCOL_TIMEOUT_MS); }
function publicCheckpoint(): PublicCheckpoint {
const state = confirmed.state;
return { turnId: state.phase === 'revealing' ? state.turnId + 1 : Math.max(1, state.turnId), energy: [...state.energy],
return { turnId: state.phase === 'revealing' ? state.turnId + 1 : Math.max(1, state.turnId), energy: [...state.energy], timeouts: state.timeouts ?? [0, 0],
finished: state.phase === 'finished', winnerIndex: state.finished?.winnerIndex ?? null, ...(state.finished ? { reason: state.finished.reason } : {}) };
}
function sameCheckpoint(a: PublicCheckpoint, b: PublicCheckpoint) {
@@ -99,28 +100,65 @@ export function createFriendMatch(config: FriendConfig) {
}
async function resolved(actions: Pair<ActionId | null>) {
clear('deadline');
engine.resolve(actions);
if (engine.view().phase !== 'finished') engine.resolve(actions);
const state = engine.view();
ownDigest = await digestValue([config.matchId, peer.epoch, state.turnId, state.energy, actions, state.finished?.winnerIndex ?? null, state.finished?.reason ?? null]);
ownDigest = await digestValue([config.matchId, peer.epoch, state.turnId, state.energy, state.timeouts, actions, state.finished?.winnerIndex ?? null, state.finished?.reason ?? null]);
if (stopped || recovering) return;
send('turn:resolved', { digest: ownDigest }); agree();
}
function applyTimeout(deadline: number) {
const state = engine.view();
if (state.phase !== 'selecting' || deadline !== state.deadline || timeoutReports.includes(null)) return;
const missing = timeoutReports as Pair<boolean>;
if (!missing.some(Boolean)) { armProtocol(); return; }
clear('protocol');
const extended = engine.expireSelection(missing, deadline);
// Persist timeout evidence even before terminal digest agreement. Recovery
// must complete the third timeout, never restore another selection window.
confirmed.state.timeouts = [...engine.view().timeouts!];
config.checkpointChanged();
if (extended) {
config.changed(); armDeadline();
} else if (engine.view().phase === 'finished') {
run(() => resolved([null, null])); armProtocol();
}
}
function armDeadline() {
const deadline = engine.view().deadline;
timeoutReports = [null, null];
later('deadline', () => {
if (!peer.connected) { void recover(); return; }
armProtocol();
run(() => {
const missing = engine.view().ownAction === null;
timeoutReports[config.slot] = missing;
// Ordered delivery puts a last-moment commitment before this report.
send('turn:timeout', { deadline, missing, slot: config.slot }); applyTimeout(deadline);
});
}, deadline - now());
}
function resetAgreement() {
privateChoice = undefined; ownDigest = remoteDigest = remoteAck = ''; sentAck = false; presented = false; presentAt = undefined;
}
function selection() {
const earlyCommit = pendingCommit; pendingTurnId = undefined; pendingCommit = undefined;
privateChoice = undefined; ownDigest = remoteDigest = remoteAck = ''; sentAck = false; presented = false; presentAt = undefined;
resetAgreement();
const state = engine.view();
exchange = createTurnExchange({ matchId: config.matchId, epoch: peer.epoch, turnId: state.turnId, slot: config.slot, energy: state.energy,
send: message => peer.send(message), onResolved: actions => run(() => resolved(actions)),
onPrivateState: value => { privateChoice = value; config.changed(); },
onPhase: phase => { if (phase !== 'commit' || now() >= state.deadline) armProtocol(); } });
onPhase: phase => { if (phase !== 'commit' || now() >= engine.view().deadline) armProtocol(); } });
if (earlyCommit) void exchange.receive(earlyCommit).catch(handleError);
config.changed();
later('deadline', () => { armProtocol(); run(() => exchange!.commit(engine.view().ownAction)); }, state.deadline - now());
armDeadline();
}
function applyStart(startsAt: number, resume = false) {
if (!Number.isFinite(startsAt)) { abort('protocol_error'); return; }
if (resume) {
engine.resumeTurn(confirmed, startsAt); recovering = false; clear('recovery'); engine.presence(true); selection(); heartbeat(); return;
engine.resumeTurn(confirmed, startsAt); recovering = false; clear('recovery'); engine.presence(true);
if (engine.view().phase === 'finished') { resetAgreement(); run(() => resolved([null, null])); armProtocol(); }
else selection();
heartbeat(); return;
}
pendingTurnId = engine.view().turnId + 1; pendingCommit = undefined;
if (!begun) { begun = true; engine.start(startsAt - matchPreparationDuration(engine.view().settings)); confirmed = engine.checkpoint(); config.changed(); }
@@ -162,13 +200,19 @@ export function createFriendMatch(config: FriendConfig) {
if (message.type === 'clock:pong') { if (Number.isFinite(message.remoteAt)) clockReplies.get(message.sentAt)?.resolve(message.remoteAt); clockReplies.delete(message.sentAt); return; }
if (message.type === 'pong') return;
if (message.type === 'emote') {
if (!recovering && engine.view().phase !== 'finished') config.onEmote?.(message.id, message.variant);
if (!recovering && engine.view().phase !== 'finished' && (message.variant === 0 || message.variant === 1)) config.onEmote?.(message.id, message.variant);
return;
}
if (message.type === 'ready') { if (message.slot === config.slot) throw new Error('INVALID_SLOT'); peerReady = true; maybeStart(); return; }
if (message.type === 'resume' || message.type === 'resume:ack') {
if (!recovering) return;
if (!sameCheckpoint(message.checkpoint, publicCheckpoint())) { abort('protocol_error'); return; }
const counts = message.checkpoint.timeouts ?? [0, 0];
if (!Array.isArray(counts) || counts.length !== 2 || counts.some(count => !Number.isInteger(count) || count < 0 || count > TIMEOUT_CHANCES)) throw new Error('INVALID_TIMEOUT');
// A disconnect can lose one timeout report. Keep the highest observed count
// while discarding unconfirmed choices, so reconnect never refunds a chance.
confirmed.state.timeouts = counts.map((count, slot) => Math.max(count, confirmed.state.timeouts?.[slot] ?? 0)) as Pair<number>;
config.checkpointChanged();
if (message.type === 'resume') { resumedPeer = true; send('resume:ack', { checkpoint: publicCheckpoint() }); }
else resumedAck = true;
maybeResume(); return;
@@ -192,6 +236,12 @@ export function createFriendMatch(config: FriendConfig) {
pendingCommit = message; return;
}
if (message.turnId !== engine.view().turnId) return;
if (message.type === 'turn:timeout') {
if (message.slot !== (config.slot === 0 ? 1 : 0) || typeof message.missing !== 'boolean') throw new Error('INVALID_TIMEOUT');
if (message.deadline !== engine.view().deadline) return;
if (timeoutReports[message.slot] !== null && timeoutReports[message.slot] !== message.missing) throw new Error('TIMEOUT_CHANGED');
timeoutReports[message.slot] = message.missing; applyTimeout(message.deadline); return;
}
if (message.type === 'turn:commit' || message.type === 'turn:reveal') { await exchange?.receive(message); return; }
if (message.type === 'turn:resolved') { if (remoteDigest && remoteDigest !== message.digest) throw new Error('RESULT_CHANGED'); remoteDigest = message.digest; agree(); }
else if (message.type === 'turn:ack') { remoteAck = message.digest; agree(); }
+1 -1
View File
@@ -20,7 +20,7 @@
</main>
<footer id="app-footer" class="app-footer"></footer>
</div>
<dialog id="rules-dialog"><div class="dialog-top"><h2>규칙</h2><button class="quiet" data-close aria-label="규칙 닫기">닫기</button></div><p>기 0으로 시작합니다. 공격에 한 번 맞으면 종료됩니다.</p><div id="rules-list"></div><p>선택은 바꿀 수 없습니다. 봇 대전은 선택 즉시, 사용자 간 대전은 양쪽 선택이 끝나면 함께 공개됩니다.</p><p>한쪽 미입력은 패배, 양쪽 미입력은 무승부입니다. 상대에게 나가기가 전달되면 기권 패배입니다.</p><p>친구 대전 연결이 끊기면 최대 10초간 복구합니다. 확정되지 않은 선택은 취소됩니다. 복구하지 못하면 전적 변경 없이 중단합니다.</p><p>같은 공격끼리는 상쇄됩니다. 공격과 순간이동은 실패해도 기를 소모합니다.</p><p>봇·친구 연승은 탭의 접속 세션별로 저장합니다. 같은 탭 새로고침은 복원되며, 저장 정보 없는 새 탭은 새 전적입니다.</p><p>랭킹은 최대 연승, 누적 승 순입니다. 무승부는 연승을 유지합니다. 랭킹 기록·캐릭터·도복은 같은 브라우저의 익명 방문자 정보에 저장됩니다.</p></dialog>
<dialog id="rules-dialog"><div class="dialog-top"><h2>규칙</h2><button class="quiet" data-close aria-label="규칙 닫기">닫기</button></div><p>기 0으로 시작합니다. 공격에 한 번 맞으면 종료됩니다.</p><div id="rules-list"></div><p>선택은 바꿀 수 없습니다. 봇 대전은 선택 즉시, 사용자 간 대전은 양쪽 선택이 끝나면 함께 공개됩니다.</p><p>시간 초과는 대전 전체에서 누적됩니다. 세 번째 시간 초과 시 패배하며, 양쪽이 동시에 세 번째이면 무승부입니다. 상대에게 나가기가 전달되면 기권 패배입니다.</p><p>친구 대전 연결이 끊기면 최대 10초간 복구합니다. 확정되지 않은 선택은 취소됩니다. 복구하지 못하면 전적 변경 없이 중단합니다.</p><p>같은 공격끼리는 상쇄됩니다. 공격과 순간이동은 실패해도 기를 소모합니다.</p><p>봇·친구 연승은 탭의 접속 세션별로 저장합니다. 같은 탭 새로고침은 복원되며, 저장 정보 없는 새 탭은 새 전적입니다.</p><p>랭킹은 최대 연승, 누적 승 순입니다. 무승부는 연승을 유지합니다. 랭킹 기록·캐릭터·도복은 같은 브라우저의 익명 방문자 정보에 저장됩니다.</p></dialog>
<dialog id="privacy-dialog"><div class="dialog-top"><h2>개인정보·분석 안내</h2><button class="quiet" data-close aria-label="개인정보·분석 안내 닫기">닫기</button></div><p>브라우저에 익명 방문자 토큰을, 탭에 전적 복원 정보를 저장합니다. 새 전적은 마지막 대전 등록·결과 저장부터 90일간 보관합니다.</p><p>익명 방문·화면 전환·메뉴 조작·대전 시작과 결과·기기 분류·유입 도메인을 수집합니다. 방문 분석은 90일간 보관하며, 과거 서버 판정 대전 기록은 계속 보관합니다.</p><p>자동으로 부여한 랭킹 이름·대전 결과·선택한 캐릭터와 도복은 계속 보관합니다. 랭킹 이름과 최대 연승·누적 승은 리더보드에 공개됩니다.</p><p>저장소를 삭제하면 이전 기록에 다시 연결할 수 없습니다.</p></dialog>
<dialog id="contact-dialog" aria-labelledby="contact-title"><div class="dialog-top"><h2 id="contact-title">문의</h2><button type="button" class="quiet" data-close aria-label="문의 닫기">닫기</button></div><div id="contact-content"><a href="mailto:sunha321@gmail.com">sunha321@gmail.com</a></div></dialog>
<script type="module" src="./main.ts"></script>
+6 -2
View File
@@ -25,10 +25,14 @@ export function createMatchController(config: ControllerConfig) {
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() {
function saveCheckpoint() {
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));
}
function publish() {
if (destroyed) return;
saveCheckpoint();
for (const listener of listeners) listener(view());
}
async function record() {
@@ -67,7 +71,7 @@ export function createMatchController(config: ControllerConfig) {
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,
setOffset: value => { offset = value; }, changed: publish, checkpointChanged: saveCheckpoint, 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;
}
+5 -4
View File
@@ -1,4 +1,4 @@
import { ACTIONS, ACTION_IDS, MATCH_COUNTDOWN_MS } from '../../constants/gameplay.js';
import { ACTIONS, ACTION_IDS, MATCH_COUNTDOWN_MS, TIMEOUT_CHANCES } from '../../constants/gameplay.js';
import type { ActionId } from '../shared/types.js';
import type { LocalMatchState } from '../shared/p2p-types.js';
import { appearanceKey, type FighterAppearance } from '../shared/cosmetics.js';
@@ -16,7 +16,7 @@ const fighter = (i: number, appearance?: FighterAppearance) => `<div class="figh
export function createMatchView(container: HTMLElement, onAction: (action: ActionId) => Promise<boolean>, onRematch: () => void, onLeave: () => void, onResult: () => void = () => {}, onRules: () => void = () => {}, appearances?: [FighterAppearance, FighterAppearance], onEmote?: (id: EmoteId) => boolean | Promise<boolean>) {
container.innerHTML = `<section class="match-introduction waiting" hidden><div class="queue-fighter" aria-hidden="true"></div><h1 role="status"></h1></section><div class="match-top"><div class="match-title"><span class="mode-label"></span><h1 class="turn-heading"></h1></div><details class="match-menu"><summary aria-label="대전 메뉴">메뉴</summary><div class="match-menu-items"><button class="match-rules quiet">규칙</button><button class="leave quiet">나가기</button></div></details></div>
<div class="timing"><span class="selection-status" role="status"></span><span class="time-label" aria-label="남은 시간"></span><div class="time-track" role="progressbar" aria-label="남은 선택 시간" aria-valuemin="0" aria-valuemax="100"><span></span></div></div>
<div class="timing"><span class="selection-status" role="status"></span><span class="timeout-chances" aria-label="남은 초읽기 기회"></span><span class="time-label" aria-label="남은 시간"></span><div class="time-track" role="progressbar" aria-label="남은 선택 시간" aria-valuemin="0" aria-valuemax="100"><span></span></div></div>
<div class="arena"><div class="arena-floor" aria-hidden="true"></div><div class="result-banner" hidden><h2></h2><p class="finish-reason"></p></div>${fighter(0, appearances?.[0])}<div class="duel-cue"><span class="versus" aria-hidden="true">VS</span><strong class="countdown-number" role="status"></strong></div><span class="clash-label" role="status"></span><div class="clash-burst" aria-hidden="true"></div>${fighter(1, appearances?.[1])}</div>
<div class="player-controls" role="group" aria-label="내 행동 선택"><div class="energy">${actionIcon('charge')}<strong class="energy-count"></strong></div><div class="action-buttons">${ACTION_IDS.map(id => `<button data-action="${id}" aria-label="${ACTIONS[id].name}, ${ACTIONS[id].effect}${ACTIONS[id].cost ? `, 기 ${ACTIONS[id].cost} 소모` : ''}"><span class="action-symbol">${actionIcon(id)}</span><strong>${ACTIONS[id].name}</strong><small>${id === 'charge' || id === 'guard' ? ACTIONS[id].effect : `기 ${ACTIONS[id].cost} 소모`}</small><span class="chosen-mark">${checkIcon}</span></button>`).join('')}</div></div>
<div class="emote-menu" id="emote-choices" role="group" aria-label="감정표현 선택" hidden>${EMOTE_IDS.map(id => `<button type="button" data-emote="${id}">${EMOTE_LABELS[id]}</button>`).join('')}</div>
@@ -28,7 +28,7 @@ export function createMatchView(container: HTMLElement, onAction: (action: Actio
function closeEmotes(restoreFocus = false) {
emoteOpen = false; emoteMenu.hidden = true;
emoteTriggers.forEach(button => button.setAttribute('aria-expanded', 'false'));
if (restoreFocus) emoteTriggers.find(button => Number(button.dataset.emoteTrigger) === current?.selfIndex)?.focus();
if (restoreFocus) emoteTriggers.find(button => Number(button.dataset.emoteTrigger) === current?.selfIndex)?.focus({ preventScroll: true });
}
emoteTriggers.forEach(button => {
button.onclick = () => {
@@ -39,7 +39,7 @@ export function createMatchView(container: HTMLElement, onAction: (action: Actio
emoteMenu.style.top = `${Math.max(72, actor.top - root.top - 104)}px`;
emoteOpen = true; tick();
button.setAttribute('aria-expanded', 'true');
emoteMenu.querySelector<HTMLButtonElement>('button:not(:disabled)')?.focus();
emoteMenu.querySelector<HTMLButtonElement>('button:not(:disabled)')?.focus({ preventScroll: true });
};
});
container.onpointerdown = event => {
@@ -116,6 +116,7 @@ export function createMatchView(container: HTMLElement, onAction: (action: Actio
if (s.phase === 'countdown' && countdown.textContent !== count) { countdown.textContent = count; countdown.dataset.count = count; }
el('.duel-cue').hidden = revealing || resultVisible || recovering;
el('.selection-status').textContent = !resultVisible && !s.opponentPresent ? '연결 복구 중' : s.phase === 'countdown' ? Number.isFinite(remaining) ? '' : '상대 연결 중' : revealing ? '동시 공개' : resultVisible ? '' : busy ? '선택 중' : s.ownAction ? '선택 완료' : remaining > 0 ? '행동 선택' : '결과 대기';
el('.timeout-chances').textContent = `초읽기 ${Math.max(0, TIMEOUT_CHANCES - (s.timeouts?.[s.selfIndex] ?? 0))}/${TIMEOUT_CHANCES}`;
el('.time-label').textContent = s.phase === 'selecting' && !revealing && !recovering ? `${(remaining / 1000).toFixed(1)}초` : '';
el('.time-track').hidden = recovering;
const progress = s.phase === 'countdown' ? remaining / MATCH_COUNTDOWN_MS : s.phase === 'selecting' ? remaining / (s.deadline - s.startsAt) : 0;
+1
View File
@@ -68,6 +68,7 @@ body:is([data-screen=match], [data-screen=result]) main { padding: 0; min-height
transform: translateX(-50%); width: 220px; min-height: 37px; padding: 0 0 13px;
z-index: 12; font-size: 11px; align-items: center; gap: 8px;
}
#match .timeout-chances { position: absolute; top: 100%; left: 50%; transform: translateX(-50%); font-size: 11px; white-space: nowrap; }
#match .time-label { font-size: 16px; white-space: nowrap; }
#match .time-track { bottom: 0; height: 4px; border-radius: 0; background: #39493c20; }
#match .time-track span { border-radius: 0; background: #a85039; }
+2 -2
View File
@@ -1,7 +1,7 @@
/* Import after game.css, match.css and home.css. */
body.player-app #match .emote-trigger,
body.player-app #match .emote-trigger:hover,
body.player-app #match .emote-trigger:active {
body.player-app #match .emote-trigger:hover:not(:disabled),
body.player-app #match .emote-trigger:active:not(:disabled) {
position: absolute; inset: 0; z-index: 8; width: 100%; height: 100%;
margin: 0; padding: 0; border: 0; border-radius: 0; background: transparent;
box-shadow: none; transform: none; cursor: pointer;
+3 -1
View File
@@ -201,7 +201,9 @@ export async function createRankedService(db: Db, clock: Clock = systemClock, en
match.actions = [null, null]; match.engine.beginTurn(state.reveal!.revealEndsAt);
}
state = match.engine.view();
if (state.phase === 'selecting' && (match.actions.every(action => action !== null) || clock.now() >= state.deadline)) match.engine.resolve(match.actions);
while (state.phase === 'selecting' && (match.actions.every(action => action !== null) || clock.now() >= state.deadline)) {
match.engine.resolve(match.actions); state = match.engine.view();
}
if (match.engine.view().phase === 'finished') {
match.finishedExpiresAt ??= [clock.now() + RANKED_FINISHED_TTL_MS, clock.now() + RANKED_FINISHED_TTL_MS];
await persist(match);
+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); },
+3 -1
View File
@@ -25,6 +25,7 @@ export interface SignalingPacket {
}
export interface IceConfiguration { iceServers: RTCIceServer[]; expiresAt: number }
export interface LocalMatchState extends Omit<MatchState, 'serverNow' | 'finished'> {
timeouts?: Pair<number>;
emotes?: Pair<Emote | null>;
settings?: GlobalSettings;
clockNow: number; aborted: boolean;
@@ -45,6 +46,7 @@ export type PeerMessage = PeerHeader & (
| { type: 'ready'; slot: 0 | 1 }
| { type: 'clock:ping' | 'ping'; sentAt: number }
| { type: 'clock:pong' | 'pong'; sentAt: number; remoteAt: number }
| { type: 'turn:timeout'; deadline: number; missing: boolean; slot: 0 | 1 }
| { type: 'turn:start'; startsAt: number; resume?: boolean }
| { type: 'turn:commit'; digest: string; slot: 0 | 1 }
| { type: 'turn:reveal'; action: ActionId | null; nonce: string; slot: 0 | 1 }
@@ -54,7 +56,7 @@ export type PeerMessage = PeerHeader & (
| { type: 'resume' | 'resume:ack'; checkpoint: PublicCheckpoint }
);
export interface PublicCheckpoint {
turnId: number; energy: Pair<number>; finished: boolean; winnerIndex: 0 | 1 | null; reason?: LocalEndReason;
turnId: number; energy: Pair<number>; timeouts?: Pair<number>; finished: boolean; winnerIndex: 0 | 1 | null; reason?: LocalEndReason;
}
export function emptyPlayerStats(): PlayerStats {
const mode = () => ({ wins: 0, losses: 0, draws: 0, current: 0, best: 0 });
+1 -1
View File
@@ -34,7 +34,7 @@ test('local bot runs countdown, turns, result and persistence without Socket.IO'
});
test('timeout records a loss once and local leave records forfeit', async () => {
const h = harness();
try { await h.controller.start(); await h.clock.advance(7000); assert.equal(h.recorded[0].outcome, 'loss'); assert.equal(h.recorded[0].reason, 'timeout'); }
try { await h.controller.start(); await h.clock.advance(12000); assert.equal(h.recorded[0].outcome, 'loss'); assert.equal(h.recorded[0].reason, 'timeout'); }
finally { h.controller.destroy(); }
const other = harness();
try { await other.controller.start(); await other.controller.leave(); assert.equal(other.recorded[0].reason, 'forfeit'); assert.equal(other.recorded[0].outcome, 'loss'); }
+23 -1
View File
@@ -39,10 +39,15 @@ test('a turn resolves once and a hit ends the game', () => {
test('only verified missing choices count as timeouts; technical abort leaves outcome separate', () => {
const { engine, at } = setup(); engine.start(1000); at(4000); engine.beginTurn(); at(8000);
engine.resolve([null, 'charge']);
assert.equal(engine.view().phase, 'selecting');
assert.deepEqual(engine.view().timeouts, [1, 0]);
at(engine.view().deadline); engine.resolve([null, 'charge']);
assert.equal(engine.view().phase, 'selecting');
at(engine.view().deadline); engine.resolve([null, 'charge']);
assert.equal(engine.view().finished?.winnerIndex, 1);
assert.equal(engine.view().finished?.reason, 'timeout');
const other = setup(); other.engine.start(1000); other.at(4000); other.engine.beginTurn(); other.at(8000);
other.engine.resolve([null, null]);
for (let i = 0; i < 3; i++) { other.at(other.engine.view().deadline); other.engine.resolve([null, null]); }
assert.equal(other.engine.view().finished?.reason, 'double_timeout');
const aborted = setup(); aborted.engine.abort('connection_failed');
assert.equal(aborted.engine.view().aborted, true);
@@ -86,3 +91,20 @@ test('rollback and recovery keep published revisions increasing so the view acce
other.engine.resumeTurn(start, 5000);
assert.ok(other.engine.view().version > previous);
});
test('timeout chances persist across turns and restore while locked actions stay locked', () => {
const { engine, at } = setup(); engine.start(1000); at(4000); engine.beginTurn(); engine.lockAction('guard');
at(engine.view().deadline); engine.resolve(['guard', null]);
assert.equal(engine.view().ownAction, 'guard');
assert.equal(engine.lockAction('charge'), false);
assert.deepEqual(engine.view().timeouts, [0, 1]);
engine.resolve(['guard', 'charge']);
at(engine.view().reveal!.revealEndsAt); engine.beginTurn();
const saved = engine.checkpoint(); engine.restore(saved);
assert.deepEqual(engine.view().timeouts, [0, 1]);
at(engine.view().deadline); engine.resolve([null, null]);
assert.deepEqual(engine.view().timeouts, [1, 2]);
at(engine.view().deadline); engine.resolve([null, null]);
assert.deepEqual(engine.view().timeouts, [2, 3]);
assert.equal(engine.view().finished?.winnerIndex, 0);
});
+94 -4
View File
@@ -1,3 +1,4 @@
import { DEFAULT_GLOBAL_SETTINGS } from '../constants/global-settings.js';
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createMatchController } from '../src/client/match-controller.js';
@@ -51,7 +52,7 @@ test('peer emotes reach the correct slot without exposing choices or changing th
assert.equal(b.sendEmote('threaten'), false);
} finally { h.close(); }
});
function setup(onRematch?: () => Promise<void>, guestClockOffset = 0, guestStartDelay = 0) {
function setup(onRematch?: () => Promise<void>, guestClockOffset = 0, guestStartDelay = 0, turnDurationMs = DEFAULT_GLOBAL_SETTINGS.turnDurationMs) {
const clock = new ManualClock(); clock.time = 1000;
const a = new Wire(), b = new Wire(); a.remote = b; b.remote = a;
const records: ResultSubmission[][] = [[], []];
@@ -61,7 +62,7 @@ function setup(onRematch?: () => Promise<void>, guestClockOffset = 0, guestStart
const storage = { getItem: (k: string) => memory.get(k) ?? null, setItem: (k: string, v: string) => { memory.set(k, v); }, removeItem: (k: string) => { memory.delete(k); } }; storages.push(storage);
const localClock = slot === 1 ? { now: () => clock.now() + guestClockOffset,
setTimeout: (fn: () => void, ms: number) => clock.setTimeout(fn, ms + (ms === 4000 || ms === 0 ? guestStartDelay : 0)), clearTimeout: clock.clearTimeout } : clock;
return createMatchController({ activeMatch: { matchId: 'friend-match', seq: 1, mode: 'friend', roomId: 'r' }, gameSessionId: `session-${slot}`, selfIndex: slot as 0 | 1, clock: localClock, peer, onRematch,
return createMatchController({ activeMatch: { matchId: 'friend-match', seq: 1, mode: 'friend', roomId: 'r', settings: { ...DEFAULT_GLOBAL_SETTINGS, turnDurationMs } }, gameSessionId: `session-${slot}`, selfIndex: slot as 0 | 1, clock: localClock, peer, onRematch,
reporter: { submit: async data => { records[slot].push(data); return { status: 'applied', stats: emptyPlayerStats() }; } },
storage,
});
@@ -161,7 +162,7 @@ test('peers whose wall clocks differ by five seconds agree on deadline and timeo
try {
await Promise.all(h.controllers.map(c => c.start())); await h.clock.advance(4000);
assert.equal(a.view().deadline, b.view().deadline); assert.equal(a.view().clockNow, b.view().clockNow);
await a.submitAction('charge'); await h.clock.advance(4000);
await a.submitAction('charge'); await h.clock.advance(9000);
assert.equal(h.records[0][0]?.outcome, 'win'); assert.equal(h.records[1][0]?.outcome, 'loss');
assert.equal(a.view().finished?.reason, 'timeout'); assert.equal(b.view().finished?.reason, 'timeout');
} finally { h.close(); }
@@ -180,7 +181,7 @@ test('unrecoverable transport interruption aborts rather than inventing two wins
test('refresh of a mutually confirmed final result saves without reconnecting to the opponent', async () => {
const h = setup();
try {
await Promise.all(h.controllers.map(c => c.start())); await h.clock.advance(7000);
await Promise.all(h.controllers.map(c => c.start())); await h.clock.advance(13000);
const { GAME_CHECKPOINT_KEY } = await import('../constants/recording.js');
const saved = JSON.parse(h.storages[0].getItem(GAME_CHECKPOINT_KEY));
saved.checkpoint.state.finished.saved = false; saved.checkpoint.state.saveStatus = 'pending';
@@ -261,3 +262,92 @@ test('loss of result agreement cannot display or save a speculative winner durin
assert.deepEqual(h.records.map(r => r.map(x => x.outcome)), [['aborted'], ['aborted']]);
} finally { h.close(); }
});
test('peer timeout extensions preserve hidden choices and cumulative chances across turns', async () => {
const h = setup(), [a, b] = h.controllers;
try {
await Promise.all(h.controllers.map(c => c.start())); await h.clock.advance(4000);
await a.submitAction('charge');
await h.clock.advance(a.view().deadline - h.clock.now() + 1);
assert.deepEqual(a.view().timeouts, [0, 1]);
assert.deepEqual(b.view().timeouts, [0, 1]);
assert.equal(a.view().ownAction, 'charge');
assert.equal(b.view().reveal, undefined);
assert.equal(await b.submitAction('guard'), true); await h.clock.advance(1);
assert.equal(a.view().phase, 'revealing');
await h.clock.advance(1200);
await a.submitAction('guard');
await h.clock.advance(a.view().deadline - h.clock.now() + 1);
assert.deepEqual(b.view().timeouts, [0, 2]);
assert.equal(b.view().phase, 'selecting');
await h.clock.advance(a.view().deadline - h.clock.now() + 1);
assert.equal(a.view().finished?.winnerIndex, 0);
assert.equal(b.view().finished?.winnerIndex, 0);
} finally { h.close(); }
});
test('a guest commitment arriving after the deadline keeps a valid last-moment choice', async () => {
const h = setup(), [a, b] = h.controllers;
try {
await Promise.all(h.controllers.map(c => c.start())); await h.clock.advance(4000);
await a.submitAction('charge'); await h.clock.advance(1);
const held: PeerMessage[] = [];
h.b.intercept = message => { if (['turn:commit', 'turn:reveal', 'turn:timeout'].includes(message.type)) { held.push(message); return true; } return false; };
await h.clock.advance(b.view().deadline - h.clock.now() - 1);
await b.submitAction('guard'); await h.clock.advance(2);
assert.equal(a.view().phase, 'selecting');
h.b.intercept = undefined; for (const message of held) h.b.send(message);
await h.clock.advance(1);
assert.equal(a.view().phase, 'revealing'); assert.equal(b.view().phase, 'revealing');
assert.deepEqual(a.view().timeouts, [0, 0]); assert.deepEqual(b.view().timeouts, [0, 0]);
assert.deepEqual(a.view().reveal?.actions, ['charge', 'guard']);
} finally { h.close(); }
});
test('recovery reconciles a timeout report lost before the other peer receives it', async () => {
const h = setup(), [a, b] = h.controllers;
try {
await Promise.all(h.controllers.map(c => c.start())); await h.clock.advance(4000);
h.a.intercept = message => message.type === 'turn:timeout';
await h.clock.advance(a.view().deadline - h.clock.now() + 1);
assert.deepEqual(a.view().timeouts, [1, 1]); assert.deepEqual(b.view().timeouts, [0, 0]);
h.a.intercept = undefined; h.a.disconnect(); h.b.disconnect();
await h.clock.advance(1000);
assert.equal(a.view().phase, 'selecting'); assert.equal(b.view().phase, 'selecting');
assert.deepEqual(a.view().timeouts, [1, 1]); assert.deepEqual(b.view().timeouts, [1, 1]);
assert.equal(a.view().aborted, false); assert.equal(b.view().aborted, false);
} finally { h.close(); }
});
test('selecting during a ten-second extension does not start the protocol timeout early', async () => {
const h = setup(undefined, 0, 0, 10000), [a, b] = h.controllers;
try {
await Promise.all(h.controllers.map(c => c.start())); await h.clock.advance(4000);
await h.clock.advance(a.view().deadline - h.clock.now() + 1000);
assert.deepEqual(a.view().timeouts, [1, 1]);
await a.submitAction('guard'); await h.clock.advance(4000);
assert.equal(h.a.epoch, 0); assert.equal(h.b.epoch, 0);
assert.equal(a.view().ownAction, 'guard');
assert.equal(await b.submitAction('charge'), true); await h.clock.advance(1);
assert.equal(a.view().phase, 'revealing');
} finally { h.close(); }
});
test('disconnect before terminal timeout agreement cannot refund the third chance', async () => {
const h = setup(), [a, b] = h.controllers;
try {
await Promise.all(h.controllers.map(c => c.start())); await h.clock.advance(4000);
await a.submitAction('guard');
const holdAgreement = (message: PeerMessage) => ['turn:resolved', 'turn:ack', 'turn:present'].includes(message.type);
h.a.intercept = h.b.intercept = holdAgreement;
await h.clock.advance(a.view().deadline - h.clock.now() + 6001);
assert.deepEqual(a.view().timeouts, [0, 3]); assert.deepEqual(b.view().timeouts, [0, 3]);
assert.deepEqual(h.records.map(records => records.length), [0, 0]);
h.a.intercept = h.b.intercept = undefined; h.a.disconnect(); h.b.disconnect();
await h.clock.advance(1000);
assert.equal(a.view().finished?.reason, 'timeout'); assert.equal(b.view().finished?.reason, 'timeout');
assert.equal(a.view().finished?.winnerIndex, 0); assert.equal(b.view().finished?.winnerIndex, 0);
assert.deepEqual(a.view().timeouts, [0, 3]);
assert.equal(h.records[0][0]?.outcome, 'win'); assert.equal(h.records[1][0]?.outcome, 'loss');
} finally { h.close(); }
});
+4 -4
View File
@@ -414,7 +414,7 @@ test('finished matches retain results on reload then expire when abandoned', int
try {
await f.service.join('player-a', f.a.gameSessionId);
const id = (await f.service.join('player-b', f.b.gameSessionId)).match!.matchId;
await f.clock.advance(DEFAULT_GLOBAL_SETTINGS.matchingDelayMs + DEFAULT_GLOBAL_SETTINGS.matchSuccessDelayMs + MATCH_COUNTDOWN_MS + TURN_DURATION_MS);
await f.clock.advance(DEFAULT_GLOBAL_SETTINGS.matchingDelayMs + DEFAULT_GLOBAL_SETTINGS.matchSuccessDelayMs + MATCH_COUNTDOWN_MS + TURN_DURATION_MS * 3);
assert.equal((await f.service.status('player-a', f.a2.gameSessionId)).match?.finished?.reason, 'double_timeout');
await f.clock.advance(RANKED_FINISHED_TTL_MS - 1);
assert.equal((await f.service.status('player-a', f.a2.gameSessionId)).match?.matchId, id);
@@ -458,7 +458,7 @@ test('deadline rejects late actions and duplicate terminal writes survive an amb
await s.action('player-a', f.a.gameSessionId, id, 1, 'guard');
assert.equal((await s.leaderboard('player-a')).self, undefined);
// Jump to the deadline without executing timers: the request itself must settle it.
f.clock.time += TURN_DURATION_MS;
f.clock.time += TURN_DURATION_MS * 3;
await assert.rejects(s.action('player-b', f.b.gameSessionId, id, 1, 'guard'), /response lost after write/);
assert.equal((await s.profile('player-a')).stats.wins, 1);
assert.equal((await s.leaderboard('player-a')).self?.wins, 1);
@@ -509,7 +509,7 @@ test('timeouts progress without polling, unlock colors only from authoritative r
const id = (await s.join('player-b', b.gameSessionId)).match!.matchId;
await clock.advance(DEFAULT_GLOBAL_SETTINGS.matchingDelayMs + DEFAULT_GLOBAL_SETTINGS.matchSuccessDelayMs + MATCH_COUNTDOWN_MS);
await s.action('player-a', a.gameSessionId, id, 1, 'guard');
await clock.advance(TURN_DURATION_MS);
await clock.advance(TURN_DURATION_MS * 3);
assert.equal((await s.status('player-b', b.gameSessionId)).match?.finished?.reason, 'timeout');
await s.leave('player-a', a.gameSessionId, id); await s.leave('player-b', b.gameSessionId, id);
}
@@ -778,7 +778,7 @@ test('ranked timer reports only a safe failure code and retries persistence even
try {
await s.join('player-a', f.a.gameSessionId);
await s.join('player-b', f.b.gameSessionId);
await f.clock.advance(DEFAULT_GLOBAL_SETTINGS.matchingDelayMs + DEFAULT_GLOBAL_SETTINGS.matchSuccessDelayMs + MATCH_COUNTDOWN_MS + TURN_DURATION_MS);
await f.clock.advance(DEFAULT_GLOBAL_SETTINGS.matchingDelayMs + DEFAULT_GLOBAL_SETTINGS.matchSuccessDelayMs + MATCH_COUNTDOWN_MS + TURN_DURATION_MS * 3);
await logged;
assert.deepEqual(codes, ['RANKED_BACKGROUND_FAILED']);
assert.equal(await records.countDocuments(), 0);