import type { LocalMatchState } from '../src/shared/p2p-types.js'; // Browser-only visual fixture. Uses the real view, rules and timings; never shipped. import { createMatchView } from '../src/client/match-view.js'; import { resolveTurn } from '../src/shared/rules.js'; import { MATCH_COUNTDOWN_MS, TURN_DURATION_MS, REVEAL_DURATION_MS } from '../constants/gameplay.js'; import type { ActionId, Pair, PlayerStats } from '../src/shared/types.js'; const container = document.querySelector('#match')!; const zero = { current: 0, best: 0, wins: 0, losses: 0, draws: 0 }; const stats: PlayerStats = { bot: { ...zero }, friend: { ...zero } }; let current: LocalMatchState, timer: ReturnType | undefined; let selfIndex: 0 | 1 = 0; const view = createMatchView(container, async action => { current = { ...current, ownAction: action, version: current.version + 1, clockNow: Date.now() }; view.update(current); return true; }, () => prepare(), () => prepare(), () => { document.body.dataset.screen = 'result'; }); function state(): LocalMatchState { const now = Date.now(); return { matchId: crypto.randomUUID(), mode: 'friend', phase: 'selecting', version: 1, selfIndex, energy: [3, 3], turnId: 1, clockNow: now, startsAt: now, deadline: now + TURN_DURATION_MS, ownAction: null, rematch: [false, false], opponentPresent: true, opponentLeft: false, saveStatus: 'saved', aborted: false }; } function show(s: LocalMatchState) { document.body.dataset.screen = 'match'; current = s; view.update(s); } function prepare() { clearTimeout(timer); const s = state(); s.phase = 'countdown'; s.startsAt += MATCH_COUNTDOWN_MS; s.deadline = s.startsAt + TURN_DURATION_MS; show(s); timer = setTimeout(() => show({ ...current, phase: 'selecting', clockNow: Date.now(), version: current.version + 1 }), MATCH_COUNTDOWN_MS); } function reveal(actions: Pair, energy: Pair = [3, 3], elapsed = 0) { clearTimeout(timer); const s = state(), now = Date.now() - elapsed, outcome = resolveTurn(energy, actions); s.energy = outcome.energy; s.phase = outcome.winnerIndex === null ? 'revealing' : 'finished'; s.reveal = { ...outcome, matchId: s.matchId, turnId: 1, actions, energyBefore: energy, revealedAt: now, revealEndsAt: now + REVEAL_DURATION_MS }; if (outcome.winnerIndex !== null) s.finished = { matchId: s.matchId, winnerIndex: outcome.winnerIndex, reason: 'hit', stats, revealEndsAt: now + REVEAL_DURATION_MS, saved: true }; show(s); if (!s.finished) timer = setTimeout(() => show({ ...current, phase: 'selecting', reveal: undefined, turnId: 2, ownAction: null, clockNow: Date.now(), deadline: Date.now() + TURN_DURATION_MS, version: current.version + 1 }), REVEAL_DURATION_MS); } function result(winnerIndex: 0 | 1 | null) { clearTimeout(timer); const s = state(); s.phase = 'finished'; s.finished = { matchId: s.matchId, winnerIndex, reason: winnerIndex === null ? 'double_timeout' : 'timeout', stats, revealEndsAt: Date.now(), saved: true }; show(s); } const scenarios: [string, () => void][] = [ ['준비', prepare], ['선택', () => { clearTimeout(timer); show(state()); }], ['충전', () => reveal(['charge', 'charge'])], ['파 명중', () => reveal(['blast', 'charge'])], ['방어', () => reveal(['guard', 'blast'])], ['상쇄', () => reveal(['blast', 'blast'])], ['순간이동', () => reveal(['dodge', 'beam'])], ['에네르기파', () => reveal(['beam', 'guard'])], ['왼쪽 피격', () => reveal(['charge', 'blast'])], ['왼쪽 에네르기파 피격', () => reveal(['guard', 'beam'])], ['에네르기파 상쇄', () => reveal(['beam', 'beam'])], ['파 회피', () => reveal(['blast', 'dodge'])], ['양쪽 막기', () => reveal(['guard', 'guard'])], ['피니시 복구', () => reveal(['blast', 'charge'], [3, 3], REVEAL_DURATION_MS + 500)], ['컷씬 중간 복구', () => reveal(['beam', 'guard'], [3, 3], 650)], ['에네르기파 표정 복구', () => reveal(['beam', 'guard'], [3, 3], 2500)], ['에네르기파 소멸 복구', () => reveal(['beam', 'guard'], [3, 3], 3100)], ['에네르기파 종료 뒤 복구', () => reveal(['guard', 'beam'], [3, 3], 60_000)], ['컷씬 종료 뒤 복구', () => reveal(['charge', 'blast'], [3, 3], 60_000)], ['컷씬 저장 응답', () => { reveal(['blast', 'charge']); timer = setTimeout(() => { show({ ...current, version: current.version + 1, clockNow: Date.now() }); timer = setTimeout(() => show({ ...current, version: current.version + 1, clockNow: Date.now() }), 4300); }, 700); }], ['컷씬 중 새 대전', () => { reveal(['blast', 'charge']); timer = setTimeout(prepare, 900); }], ['패배', () => result(selfIndex === 0 ? 1 : 0)], ['승리', () => result(selfIndex)], ['무승부', () => result(null)], ['기 부족', () => { clearTimeout(timer); show({ ...state(), energy: [0, 0] }); }], ['연결 복구 중', () => { clearTimeout(timer); show({ ...state(), opponentPresent: false }); }], ['준비 중 연결 끊김', () => { clearTimeout(timer); show({ ...state(), phase: 'countdown', opponentPresent: false, startsAt: Date.now() + MATCH_COUNTDOWN_MS }); }], ['연결 복구 실패', () => { result(null); show({ ...current, aborted: true, finished: { ...current.finished!, reason: 'connection_failed' } }); }], ['중단 기록 저장 실패', () => { result(null); show({ ...current, aborted: true, saveStatus: 'failed', finished: { ...current.finished!, reason: 'connection_failed', saved: false } }); }], ['오른쪽 플레이어', () => { selfIndex = selfIndex === 0 ? 1 : 0; clearTimeout(timer); show(state()); }], ]; const picker = document.createElement('select'); picker.setAttribute('aria-label', '검증 상태'); for (const [label] of scenarios) { const option = document.createElement('option'); option.textContent = label; option.value = label; picker.append(option); } const replay = document.createElement('button'); replay.textContent = '재생'; const run = () => scenarios.find(([label]) => label === picker.value)![1](); picker.onchange = run; replay.onclick = run; document.querySelector('#scenarios')!.append(picker, replay); prepare();