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
+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;