Files
ssessesse-ki-battle/tests/ui-scenarios.ts
T

89 lines
6.0 KiB
TypeScript

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<HTMLElement>('#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<typeof setTimeout> | 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<ActionId>, energy: Pair<number> = [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();