diff --git a/constants/gameplay.ts b/constants/gameplay.ts index 30db73c..a601899 100644 --- a/constants/gameplay.ts +++ b/constants/gameplay.ts @@ -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'; diff --git a/src/client/friend-match.ts b/src/client/friend-match.ts index 0c289d8..b73a7eb 100644 --- a/src/client/friend-match.ts +++ b/src/client/friend-match.ts @@ -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; 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 = [null, null]; let pendingTurnId: number | undefined, pendingCommit: Extract | 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) { 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; + 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; + 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(); } diff --git a/src/client/index.html b/src/client/index.html index 15a288f..b8bd069 100644 --- a/src/client/index.html +++ b/src/client/index.html @@ -20,7 +20,7 @@
-

규칙

기 0으로 시작합니다. 공격에 한 번 맞으면 종료됩니다.

선택은 바꿀 수 없습니다. 봇 대전은 선택 즉시, 사용자 간 대전은 양쪽 선택이 끝나면 함께 공개됩니다.

한쪽 미입력은 패배, 양쪽 미입력은 무승부입니다. 상대에게 나가기가 전달되면 기권 패배입니다.

친구 대전 연결이 끊기면 최대 10초간 복구합니다. 확정되지 않은 선택은 취소됩니다. 복구하지 못하면 전적 변경 없이 중단합니다.

같은 공격끼리는 상쇄됩니다. 공격과 순간이동은 실패해도 기를 소모합니다.

봇·친구 연승은 탭의 접속 세션별로 저장합니다. 같은 탭 새로고침은 복원되며, 저장 정보 없는 새 탭은 새 전적입니다.

랭킹은 최대 연승, 누적 승 순입니다. 무승부는 연승을 유지합니다. 랭킹 기록·캐릭터·도복은 같은 브라우저의 익명 방문자 정보에 저장됩니다.

+

규칙

기 0으로 시작합니다. 공격에 한 번 맞으면 종료됩니다.

선택은 바꿀 수 없습니다. 봇 대전은 선택 즉시, 사용자 간 대전은 양쪽 선택이 끝나면 함께 공개됩니다.

시간 초과는 대전 전체에서 누적됩니다. 세 번째 시간 초과 시 패배하며, 양쪽이 동시에 세 번째이면 무승부입니다. 상대에게 나가기가 전달되면 기권 패배입니다.

친구 대전 연결이 끊기면 최대 10초간 복구합니다. 확정되지 않은 선택은 취소됩니다. 복구하지 못하면 전적 변경 없이 중단합니다.

같은 공격끼리는 상쇄됩니다. 공격과 순간이동은 실패해도 기를 소모합니다.

봇·친구 연승은 탭의 접속 세션별로 저장합니다. 같은 탭 새로고침은 복원되며, 저장 정보 없는 새 탭은 새 전적입니다.

랭킹은 최대 연승, 누적 승 순입니다. 무승부는 연승을 유지합니다. 랭킹 기록·캐릭터·도복은 같은 브라우저의 익명 방문자 정보에 저장됩니다.

개인정보·분석 안내

브라우저에 익명 방문자 토큰을, 탭에 전적 복원 정보를 저장합니다. 새 전적은 마지막 대전 등록·결과 저장부터 90일간 보관합니다.

익명 방문·화면 전환·메뉴 조작·대전 시작과 결과·기기 분류·유입 도메인을 수집합니다. 방문 분석은 90일간 보관하며, 과거 서버 판정 대전 기록은 계속 보관합니다.

자동으로 부여한 랭킹 이름·대전 결과·선택한 캐릭터와 도복은 계속 보관합니다. 랭킹 이름과 최대 연승·누적 승은 리더보드에 공개됩니다.

저장소를 삭제하면 이전 기록에 다시 연결할 수 없습니다.

문의

diff --git a/src/client/match-controller.ts b/src/client/match-controller.ts index ab938ef..ab2ec03 100644 --- a/src/client/match-controller.ts +++ b/src/client/match-controller.ts @@ -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; } diff --git a/src/client/match-view.ts b/src/client/match-view.ts index ee26357..4ba1df1 100644 --- a/src/client/match-view.ts +++ b/src/client/match-view.ts @@ -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) => `