Initial commit: ssessesse ki battle
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
import { ACTIONS } from '../../constants/gameplay.js';
|
||||
import type { TurnReveal } from '../shared/types.js';
|
||||
|
||||
export function createActionRenderer(container: HTMLElement) {
|
||||
let last = '', timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const reset = () => {
|
||||
clearTimeout(timer); last = '';
|
||||
delete container.dataset.outcome;
|
||||
delete container.dataset.hitIndex;
|
||||
container.style.removeProperty('--impact-x');
|
||||
container.style.removeProperty('--reveal-duration');
|
||||
container.style.removeProperty('--elapsed');
|
||||
container.querySelectorAll<HTMLElement>('[data-fighter]').forEach(f => {
|
||||
delete f.dataset.action; delete f.dataset.hit; delete f.dataset.reaction;
|
||||
f.querySelector('.action-label')!.textContent = '';
|
||||
f.querySelector('.energy-change')!.textContent = '';
|
||||
});
|
||||
container.querySelector('.clash-label')!.textContent = '';
|
||||
};
|
||||
return {
|
||||
play(reveal: TurnReveal, serverNow: number) {
|
||||
const id = `${reveal.matchId}:${reveal.turnId}`;
|
||||
if (id === last || serverNow >= reveal.revealEndsAt) return;
|
||||
reset(); last = id;
|
||||
// Effects follow the authoritative reveal window, including reconnects.
|
||||
container.style.setProperty('--reveal-duration', `${reveal.revealEndsAt - reveal.revealedAt}ms`);
|
||||
container.style.setProperty('--elapsed', `${-Math.min(reveal.revealEndsAt - reveal.revealedAt, Math.max(0, serverNow - reveal.revealedAt))}ms`);
|
||||
const attacks = ['blast', 'beam'];
|
||||
const [a, b] = reveal.actions;
|
||||
const outcome = reveal.hitIndex !== null ? 'hit' : attacks.includes(a) && a === b ? 'clash'
|
||||
: ((a === 'guard' && b === 'blast') || (b === 'guard' && a === 'blast')) ? 'block'
|
||||
: ((a === 'dodge' && attacks.includes(b)) || (b === 'dodge' && attacks.includes(a))) ? 'dodge'
|
||||
: a === 'charge' || b === 'charge' ? 'charge' : 'safe';
|
||||
container.dataset.outcome = outcome;
|
||||
if (reveal.hitIndex !== null) {
|
||||
container.dataset.hitIndex = String(reveal.hitIndex);
|
||||
container.style.setProperty('--impact-x', reveal.hitIndex === 0 ? '27%' : '73%');
|
||||
}
|
||||
container.querySelectorAll<HTMLElement>('[data-fighter]').forEach((fighter, i) => {
|
||||
fighter.dataset.action = reveal.actions[i]; fighter.dataset.hit = String(reveal.hitIndex === i);
|
||||
if (attacks.includes(reveal.actions[i])) {
|
||||
const source = fighter.querySelector('.avatar')!.getBoundingClientRect();
|
||||
const target = container.querySelector(`[data-fighter="${1 - i}"] .avatar`)!.getBoundingClientRect();
|
||||
const arena = container.getBoundingClientRect();
|
||||
const shotWidth = reveal.actions[i] === 'beam' ? .75 : .44;
|
||||
const start = source.left + source.width * (i === 0 ? .78 + shotWidth / 2 : .22 - shotWidth / 2);
|
||||
const contact = outcome === 'clash' ? arena.left + arena.width / 2
|
||||
: target.left + target.width * (outcome === 'block' ? i === 0 ? .25 : .75 : .5);
|
||||
fighter.style.setProperty('--shot-distance', `${contact - start}px`);
|
||||
}
|
||||
fighter.querySelector('.action-label')!.textContent = ACTIONS[reveal.actions[i]].name;
|
||||
const delta = reveal.energy[i] - reveal.energyBefore[i];
|
||||
fighter.querySelector('.energy-change')!.textContent = fighter.classList.contains('self') && delta ? `${delta > 0 ? '+' : ''}${delta}` : '';
|
||||
});
|
||||
container.querySelector('.clash-label')!.textContent = ({ hit: '명중!', clash: '상쇄', block: '방어!', dodge: '회피!', charge: '기 충전', safe: '' })[outcome];
|
||||
timer = setTimeout(reset, Math.max(0, reveal.revealEndsAt - serverNow));
|
||||
}, reset, destroy: reset,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
.admin-app{max-width:1400px;margin:auto;padding:0 32px}.admin-app header{height:90px}.admin-app h1{font-size:23px;letter-spacing:-.6px}.admin-app header>span{font-size:12px;color:#647086}#filters{display:flex;flex-wrap:wrap;gap:12px;align-items:flex-end;margin:28px 0 16px}#filters label{display:flex;flex-direction:column;gap:7px;font-size:12px;font-weight:700}#filters input,#filters select{font-size:13px;max-width:205px;border-radius:10px}#filters input[name=source]{width:130px}.admin-meta{font-size:12px;color:#647086;line-height:1.8;margin:8px 0 14px}#error{color:#a42c20;font-size:13px;line-height:1.8}.admin-app nav{display:flex;gap:8px;padding:14px 0;border-bottom:1px solid var(--line);overflow:auto}.admin-app nav button{font-size:13px;min-height:42px;white-space:nowrap}.admin-app nav button[aria-pressed=true]{border-color:#172033;background:#172033;color:white}.admin-app main{display:block;padding:24px 0}.report-section,.user-detail:not(:empty){background:white;border:1px solid var(--line);border-radius:18px;padding:22px;margin-bottom:18px}.report-section h2,.user-detail h2{font-size:18px;margin:0 0 14px}.report-section h3,.user-detail h3{font-size:14px;margin:24px 0 10px}.metric-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:12px;margin:14px 0}.metric{background:#f6f7f9;padding:18px;border-radius:12px;min-width:0}.metric>span{display:block;font-size:12px;color:#647086;margin-bottom:9px}.metric>strong{display:block;font-size:27px;line-height:1.35;overflow-wrap:anywhere;font-variant-numeric:tabular-nums}.table-scroll{overflow:auto}table{border-collapse:collapse;width:100%;font-size:12px;white-space:nowrap;margin:12px 0}th,td{padding:12px 14px;text-align:left;border-bottom:1px solid #edf0f5;font-variant-numeric:tabular-nums}th{font-weight:700;color:#596477;background:#f9fafc}td:first-child,th:first-child{padding-left:10px}.chart{width:100%;max-height:240px;margin:14px 0}.chart text{font-size:11px;fill:#647086}.pager{display:flex;align-items:center;gap:18px;justify-content:center;margin-top:22px;font-size:13px}.pager button{font-size:13px}.user-detail .metric strong{font-size:16px}@media(max-width:700px){.admin-app{padding:0 16px}.admin-app header{height:auto;padding:20px 0;display:block}.admin-app header>span{display:block;margin-top:8px}#filters{gap:10px}#filters label{flex:1;min-width:140px}#filters input,#filters select{width:100%;max-width:none}#filters input[name=source]{width:100%}.report-section,.user-detail:not(:empty){padding:16px}.metric-grid{grid-template-columns:repeat(2,1fr)}.metric{padding:14px}.metric>strong{font-size:23px}.chart{min-width:500px}.report-section{overflow:hidden}}
|
||||
@@ -0,0 +1,135 @@
|
||||
import type { Ratio } from '../shared/analytics-types.js';
|
||||
import './styles.css';
|
||||
import './admin.css';
|
||||
const form = document.getElementById('filters')! as HTMLFormElement, content = document.getElementById('report')!;
|
||||
let report: any, tab = 'overview', detailId = '', detailPage = 1, reportLoadError = '';
|
||||
const number = (value: unknown) => typeof value === 'number' ? value.toLocaleString('ko-KR', { maximumFractionDigits: 1 }) : '데이터 없음';
|
||||
const rate = (r: Ratio | null | undefined) => r?.percent === null || !r ? '데이터 없음' : `${number(r.percent)}% (${number(r.numerator)}/${number(r.denominator)})`;
|
||||
const time = (value: unknown) => value ? new Date(String(value)).toLocaleString('ko-KR', { timeZone: 'Asia/Seoul', hour12: false }) : '데이터 없음';
|
||||
const short = (value: string) => value ? value.slice(0, 8) : '—';
|
||||
function node(tag: string, text?: string, className?: string) {
|
||||
const el = document.createElement(tag); if (text !== undefined) el.textContent = text; if (className) el.className = className; return el;
|
||||
}
|
||||
function section(title: string, note?: string) {
|
||||
const el = node('section', undefined, 'report-section'); el.append(node('h2', title)); if (note) el.append(node('p', note, 'admin-meta')); content.append(el); return el;
|
||||
}
|
||||
function table(parent: HTMLElement, headers: string[], rows: unknown[][]) {
|
||||
const wrap = node('div', undefined, 'table-scroll'), t = node('table'), head = node('tr');
|
||||
headers.forEach(h => { const cell = node('th', h); cell.setAttribute('scope', 'col'); head.append(cell); });
|
||||
const thead = node('thead'); thead.append(head); t.append(thead);
|
||||
const body = node('tbody');
|
||||
for (const row of rows) { const tr = node('tr'); for (const value of row) { const cell = node('td'); if (value instanceof Node) cell.append(value); else cell.textContent = String(value ?? '—'); tr.append(cell); } body.append(tr); }
|
||||
t.append(body); wrap.append(t); parent.append(wrap); if (!rows.length) parent.append(node('p', '데이터 없음', 'admin-meta'));
|
||||
}
|
||||
function cards(parent: HTMLElement, values: [string, unknown][]) {
|
||||
const grid = node('div', undefined, 'metric-grid');
|
||||
for (const [label, value] of values) { const card = node('div', undefined, 'metric'); card.append(node('span', label), node('strong', typeof value === 'number' ? number(value) : String(value ?? '데이터 없음'))); grid.append(card); } parent.append(grid);
|
||||
}
|
||||
function chart(parent: HTMLElement, values: any[], key: string, labelKey: string, color: string) {
|
||||
if (!values.length) return;
|
||||
const ns = 'http://www.w3.org/2000/svg', svg = document.createElementNS(ns, 'svg'); svg.setAttribute('viewBox', '0 0 800 180'); svg.setAttribute('role', 'img'); svg.setAttribute('aria-label', `${key} 추이`); svg.classList.add('chart');
|
||||
const max = Math.max(1, ...values.map(v => v[key]));
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const y = 140 - i * 60, line = document.createElementNS(ns, 'line'); line.setAttribute('x1', '35'); line.setAttribute('x2', '780'); line.setAttribute('y1', String(y)); line.setAttribute('y2', String(y)); line.setAttribute('stroke', '#e5e9f0'); svg.append(line);
|
||||
const text = document.createElementNS(ns, 'text'); text.setAttribute('x', '0'); text.setAttribute('y', String(y + 4)); text.textContent = number(max * i / 2); svg.append(text);
|
||||
}
|
||||
const step = 730 / Math.max(1, values.length);
|
||||
values.forEach((v, i) => {
|
||||
const rect = document.createElementNS(ns, 'rect'), height = v[key] / max * 120;
|
||||
rect.setAttribute('x', String(40 + i * step)); rect.setAttribute('y', String(140 - height)); rect.setAttribute('width', String(Math.max(1, step - 4))); rect.setAttribute('height', String(height)); rect.setAttribute('rx', '3'); rect.setAttribute('fill', color);
|
||||
const title = document.createElementNS(ns, 'title'); title.textContent = `${v[labelKey]}: ${v[key]}`; rect.append(title); svg.append(rect);
|
||||
if (i === 0 || i === values.length - 1 || (values.length < 10)) { const label = document.createElementNS(ns, 'text'); label.setAttribute('x', String(40 + i * step)); label.setAttribute('y', '165'); label.textContent = String(v[labelKey]).slice(labelKey === 'day' ? 5 : 5); svg.append(label); }
|
||||
}); parent.append(svg);
|
||||
}
|
||||
function distributions(parent: HTMLElement, title: string, rows: any[], unit = '') {
|
||||
parent.append(node('h3', title)); table(parent, ['구간', '표본 수'], rows.map(r => [`${number(r.lower)}${unit} ~ ${r.upper === null ? '' : `${number(r.upper)}${unit} 미만`}`, number(r.count)]));
|
||||
}
|
||||
function render() {
|
||||
content.replaceChildren(); if (!report) return;
|
||||
if (tab === 'overview' || tab === 'gameplay') {
|
||||
const g = report.clientGameplay, current = section('대전 요약 · 클라이언트 보고', '친구 대전 결과는 참가자별 보고 수입니다.');
|
||||
cards(current, [['플레이어', g.players], ['시작 대전', g.startedMatches], ['결과 보고', g.resultReports]]);
|
||||
table(current, ['결과', '보고 수'], g.outcomes.map((r: any) => [({ win: '승', loss: '패', draw: '무', aborted: '중단' } as any)[r.name] ?? r.name, r.count]));
|
||||
}
|
||||
if (tab === 'overview') {
|
||||
const o = report.overview, s = section('방문·플레이', '순방문자·플레이어는 전체 구간 중복 제거. 방문 지표에는 모드 필터 미적용.');
|
||||
cards(s, [['순방문자', o.visitors], ['방문 세션', o.visitSessions], ['페이지 조회', o.pageViews]]);
|
||||
table(s, ['지표', '값'], [['신규 방문자', rate(o.newVisitors)], ['재방문자', rate(o.returningVisitors)]]);
|
||||
const live = section('현재 현황', '과거 기간·분류 필터 미적용 · 활성 방문자: 보이는 화면의 최근 90초 활동 · 10초 갱신'); live.id = 'live'; renderLive();
|
||||
cards(section('HTTP 요청', '서버 시작 이후 · 사람 집계와 별도 · 관리자 조회 제외'), [['요청 수', report.collection.httpRequests]]);
|
||||
const active = section('DAU · WAU · MAU', '조회 종료일을 포함한 최근 1/7/30일의 ID 중복 제거'); table(active, ['기간', '방문자', '플레이어'], [['DAU', number(o.dau.visitors), number(o.dau.players)], ['WAU', number(o.wau.visitors), number(o.wau.players)], ['MAU', number(o.mau.visitors), number(o.mau.players)]]);
|
||||
const days = section('일별 추이', 'KST 날짜 · 방문자/플레이어는 일별 중복 제거'); chart(days, report.daily, 'visitors', 'day', '#326dde'); table(days, ['날짜', '방문자', '플레이어', '생성 대전', '페이지 조회'], report.daily.map((d: any) => [d.day, d.visitors, d.players, d.matches, d.pageViews]));
|
||||
const hours = section('시간별 추이', 'KST 시각 · 파란 막대: 순방문자'); chart(hours, report.hourly, 'visitors', 'hour', '#326dde'); table(hours, ['시간', '순방문자', '생성 대전'], report.hourly.map((d: any) => [d.hour.replace('T', ' '), d.visitors, d.matches]));
|
||||
const peak = section('최대 동시 이용 표본', '10초 간격 표본 최대값 · 순간 최대값 미측정'); cards(peak, [['표본 수', report.sampledPeak.count], ['활성 방문자 추정', report.sampledPeak.active]]);
|
||||
} else if (tab === 'acquisition') {
|
||||
for (const [key, label] of [['source', '유입 채널'], ['referrer', '외부 유입 도메인'], ['device', '기기'], ['browser', '브라우저']]) {
|
||||
table(section(label), ['분류', '방문자', '페이지 조회'], report.acquisition[key].map((g: any) => [g.name, g.visitors, g.pageViews]));
|
||||
}
|
||||
table(section('초대 링크', '페이지 조회 기준'), ['초대 링크 조회', '전체 조회'], [[report.acquisition.invitation.invited, report.acquisition.invitation.total]]);
|
||||
table(section('조작'), ['조작', '횟수'], report.flow.actions.map((r: any) => [({ bot_start: '봇 시작', room_create: '방 생성', room_join: '방 참가', rematch: '재대전', room_leave: '나가기' } as any)[r.name] ?? r.name, r.count]));
|
||||
table(section('화면 전환'), ['화면', '전환 수'], report.flow.screens.map((r: any) => [r.name, r.count]));
|
||||
} else if (tab === 'revisit') {
|
||||
table(section('방문 재방문', '첫 방문 KST 날짜 기준 · 관찰일이 끝나지 않은 코호트는 관찰 중'), ['첫 날짜', '코호트 인원', 'D1', 'D7'], report.revisit.visitCohorts.map((c: any) => [c.day, c.size, c.d1 === null ? '관찰 중' : rate(c.d1), c.d7 === null ? '관찰 중' : rate(c.d7)]));
|
||||
const r = report.revisit;
|
||||
const active = section('활성 이용 시간', '보이는 화면의 연속 신호 간격만 합산 · 90초 초과 공백 제외 · 세션 전체 누적값, 기간 경계 분할 미적용'); cards(active, [['중앙값', r.activeMs.median === null ? '데이터 없음' : `${number(r.activeMs.median / 1000)}초`]]); distributions(active, '활성 시간(ms)', r.activeMs.distribution);
|
||||
} else if (tab === 'gameplay') {
|
||||
table(section('클라이언트 오류'), ['오류 코드', '횟수'], report.errors.client.map((e: any) => [e.name, e.count]));
|
||||
} else {
|
||||
const users = section('익명 사용자', '조회 구간 방문자 · 최대 50명 · 표시 ID는 내부 익명 ID의 앞 8자리');
|
||||
table(users, ['ID', '첫 방문', '최근 방문', '보고 대전', '조회'], report.users.map((u: any) => {
|
||||
const b = node('button', '상세', 'quiet'); b.onclick = () => { detailId = u.playerId; detailPage = 1; void loadDetail(); }; return [short(u.playerId), time(u.firstVisitAt), time(u.lastVisitAt), u.clientMatches, b];
|
||||
}));
|
||||
content.append(node('div', undefined, 'user-detail')); if (detailId) void loadDetail();
|
||||
}
|
||||
}
|
||||
function renderLive() {
|
||||
const el = document.getElementById('live'); if (!el || !report) return;
|
||||
el.querySelector('.metric-grid')?.remove(); el.querySelector('.live-time')?.remove(); const l = report.live;
|
||||
cards(el, [['활성 방문자 추정', l.active], ['대전 중 사람', '미수집'], ['대전 수', '미수집']]);
|
||||
el.append(node('p', `조회 ${time(l.observedAt)} · 마지막 활동 ${time(l.lastActivityAt)}${l.lastActivityAt && Date.now() - +new Date(l.lastActivityAt) > 90000 ? ' · 최근 활동 없음' : ''}`, 'admin-meta live-time'));
|
||||
}
|
||||
function collection() {
|
||||
if (!report) return;
|
||||
const c = report.collection;
|
||||
document.getElementById('collection')!.textContent = `조회 ${time(c.now)} · 최근 수집 성공 ${time(c.lastSuccessAt)} · 실패 ${c.failures} · 버림 ${c.dropped} · 제한 ${c.rateLimited} · 대기 ${c.queued} · 기록 저장 실패 ${c.resultSaveFailures} · 서버 시작 ${time(c.restartedAt)}`;
|
||||
document.getElementById('error')!.textContent = reportLoadError || report.integrityIssues?.join(' · ') || '';
|
||||
}
|
||||
async function load() {
|
||||
const params = new URLSearchParams(); const values = new FormData(form);
|
||||
for (const key of ['start', 'end', 'mode', 'audience', 'device', 'browser', 'source']) {
|
||||
const value = String(values.get(key) ?? ''); if (!value) continue;
|
||||
params.set(key, ['start', 'end'].includes(key) ? new Date(`${value}:00+09:00`).toISOString() : value);
|
||||
}
|
||||
try {
|
||||
const response = await fetch(`/api/admin/analytics/report?${params}`, { cache: 'no-store' }); const data = await response.json();
|
||||
if (!response.ok) throw new Error(data.error ?? '조회하지 못했습니다.'); report = data; reportLoadError = '';
|
||||
document.getElementById('range')!.textContent = `${time(report.range.start)} 이상 ~ ${time(report.range.end)} 미만 · 상세 보관 시작 ${time(report.range.retentionStart)} · 최초 수집 ${time(report.range.collectionStart)}`;
|
||||
collection(); render();
|
||||
} catch (error) { reportLoadError = error instanceof Error ? error.message : '조회하지 못했습니다.'; document.getElementById('error')!.textContent = reportLoadError; }
|
||||
}
|
||||
async function loadDetail() {
|
||||
const holder = content.querySelector<HTMLElement>('.user-detail'); if (!holder || !detailId) return;
|
||||
const id = detailId, page = detailPage;
|
||||
try {
|
||||
const response = await fetch(`/api/admin/analytics/users/${encodeURIComponent(id)}?page=${page}&pageSize=20`); const d = await response.json();
|
||||
if (!response.ok) throw new Error(d.error); if (detailId !== id || detailPage !== page) return;
|
||||
holder.replaceChildren(node('h2', `사용자 ${short(d.playerId)}`));
|
||||
cards(holder, [['첫 방문', time(d.firstVisitAt)], ['최근 방문', time(d.lastVisitAt)]]);
|
||||
holder.append(node('h3', '접속 세션별 전적 · 클라이언트 보고'));
|
||||
table(holder, ['게임 세션', '모드', '승', '패', '무', '현재 연승', '최고 연승'], (d.gameSessions ?? []).flatMap((s: any) => ['bot', 'friend'].map(m => [short(s.gameSessionId), m === 'bot' ? '봇' : '친구', s.stats[m].wins, s.stats[m].losses, s.stats[m].draws, s.stats[m].current, s.stats[m].best])));
|
||||
holder.append(node('h3', '방문 세션')); table(holder, ['시작', '최근 활동', '활성 시간', '기기', '브라우저', '유입'], d.sessions.map((s: any) => [time(s.startedAt), time(s.lastAt), `${number(s.activeMs / 1000)}초`, s.metadata.device, s.metadata.browser, s.metadata.source]));
|
||||
holder.append(node('h3', '이벤트 흐름')); table(holder, ['시각', '유형', '조작·코드', '화면', '대전'], d.events.map((e: any) => [time(e.occurredAt), e.eventType, e.action ?? e.code ?? '—', e.screen, short(e.matchId)]));
|
||||
const pager = node('div', undefined, 'pager'), prev = node('button', '이전'), next = node('button', '다음'); (prev as HTMLButtonElement).disabled = page <= 1; (next as HTMLButtonElement).disabled = !d.hasMore;
|
||||
prev.onclick = () => { detailPage--; void loadDetail(); }; next.onclick = () => { detailPage++; void loadDetail(); }; pager.append(prev, node('span', `${page} 페이지`), next); holder.append(pager);
|
||||
} catch (error) { holder.textContent = error instanceof Error ? error.message : '상세 조회 실패'; }
|
||||
}
|
||||
const localDate = (value: number) => new Date(value + 9 * 3600000).toISOString().slice(0, 16);
|
||||
(form.elements.namedItem('start') as HTMLInputElement).value = localDate(Date.now() - 7 * 86400000);
|
||||
(form.elements.namedItem('end') as HTMLInputElement).value = localDate(Date.now());
|
||||
form.onsubmit = e => { e.preventDefault(); void load(); };
|
||||
document.querySelectorAll<HTMLButtonElement>('[data-tab]').forEach(b => { b.onclick = () => { tab = b.dataset.tab!; document.querySelectorAll('[data-tab]').forEach(n => n.setAttribute('aria-pressed', String(n === b))); render(); }; });
|
||||
setInterval(async () => {
|
||||
if (!report || document.hidden) return;
|
||||
try { const response = await fetch('/api/admin/analytics/live', { cache: 'no-store' }); if (!response.ok) return; const data = await response.json(); report.live = data.live; report.collection = data.collection; renderLive(); collection(); } catch {}
|
||||
}, 10000);
|
||||
void load();
|
||||
@@ -0,0 +1,6 @@
|
||||
<!doctype html><html lang="ko"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>방문·게임 분석</title></head><body>
|
||||
<div class="admin-app"><header><h1>방문·게임 분석</h1><span>익명 브라우저 기준 · Asia/Seoul</span></header>
|
||||
<form id="filters"><label>시작<input name="start" type="datetime-local" required></label><label>종료<input name="end" type="datetime-local" required></label><label>모드<select name="mode"><option value="">전체</option><option value="bot">봇</option><option value="friend">친구</option></select></label><label>방문 유형<select name="audience"><option value="">전체</option><option value="new">신규</option><option value="returning">재방문</option></select></label><label>기기<select name="device"><option value="">전체</option><option value="pc">PC</option><option value="mobile">모바일</option><option value="tablet">태블릿</option></select></label><label>브라우저<select name="browser"><option value="">전체</option><option>Chrome</option><option>Edge</option><option>Firefox</option><option>Safari</option><option>Other</option></select></label><label>유입 채널<input name="source" maxlength="80"></label><button class="primary" type="submit">조회</button></form>
|
||||
<p id="range" class="admin-meta"></p><p id="collection" class="admin-meta" role="status"></p><p id="error" role="alert"></p>
|
||||
<nav aria-label="분석 메뉴"><button data-tab="overview" aria-pressed="true">개요</button><button data-tab="acquisition" aria-pressed="false">유입·흐름</button><button data-tab="revisit" aria-pressed="false">재방문</button><button data-tab="gameplay" aria-pressed="false">게임</button><button data-tab="users" aria-pressed="false">사용자</button></nav>
|
||||
<main id="report"></main></div><script type="module" src="../../admin.ts"></script></body></html>
|
||||
@@ -0,0 +1,62 @@
|
||||
import { ACTIVITY_INTERVAL_MS, ANALYTICS_BATCH_SIZE, RETRY_INTERVAL_MS, RETRY_MAX_AGE_MS, RETRY_QUEUE_LIMIT } from '../../constants/analytics.js';
|
||||
interface AnalyticsTransport { events(events: Record<string, unknown>[]): Promise<unknown>; activity(visible: boolean): Promise<unknown> }
|
||||
export function createClientAnalytics(transport: AnalyticsTransport) {
|
||||
const queue = new Map<string, { data: Record<string, unknown>; at: number; attempts: number }>();
|
||||
let sending = false, pageSent = false, enabled = false, destroyed = false, lastVisible = -Infinity, lastHidden = -Infinity;
|
||||
function event(eventType: string, fields: Record<string, unknown> = {}) {
|
||||
if (queue.size >= RETRY_QUEUE_LIMIT) return;
|
||||
const eventId = crypto.randomUUID(); queue.set(eventId, { data: { eventId, eventType, ...fields }, at: Date.now(), attempts: 0 });
|
||||
if (queue.size >= ANALYTICS_BATCH_SIZE) void flush();
|
||||
}
|
||||
async function flush() {
|
||||
if (sending || !enabled || destroyed) return;
|
||||
sending = true;
|
||||
for (const [id, item] of queue) if (Date.now() - item.at > RETRY_MAX_AGE_MS || item.attempts >= 6) queue.delete(id);
|
||||
while (queue.size && !destroyed) {
|
||||
const batch = [...queue.entries()].slice(0, ANALYTICS_BATCH_SIZE);
|
||||
try {
|
||||
await transport.events(batch.map(([, item]) => ({ ...item.data, delayed: ++item.attempts > 1 })));
|
||||
batch.forEach(([id]) => queue.delete(id));
|
||||
} catch (error) {
|
||||
if ((error as { code?: string }).code === 'INVALID_ANALYTICS_EVENT') batch.forEach(([id]) => queue.delete(id));
|
||||
break;
|
||||
}
|
||||
if (queue.size < ANALYTICS_BATCH_SIZE) break;
|
||||
}
|
||||
sending = false;
|
||||
}
|
||||
function activity() {
|
||||
if (!enabled || destroyed) return;
|
||||
const visible = !document.hidden, now = Date.now();
|
||||
if (now - (visible ? lastVisible : lastHidden) < ACTIVITY_INTERVAL_MS) return;
|
||||
if (visible) lastVisible = now; else lastHidden = now;
|
||||
void transport.activity(visible).catch(() => {});
|
||||
}
|
||||
const heartbeat = setInterval(() => { if (!document.hidden) activity(); }, ACTIVITY_INTERVAL_MS);
|
||||
const retry = setInterval(() => { void flush(); }, RETRY_INTERVAL_MS);
|
||||
document.addEventListener('visibilitychange', activity);
|
||||
return {
|
||||
ready() {
|
||||
enabled = true;
|
||||
activity();
|
||||
if (!pageSent) {
|
||||
pageSent = true;
|
||||
const ua = navigator.userAgent;
|
||||
const browser = /Edg\/(\d+)/.exec(ua) ?? /Firefox\/(\d+)/.exec(ua) ?? /Chrome\/(\d+)/.exec(ua) ?? /Version\/(\d+).*Safari/.exec(ua);
|
||||
const name = /Edg\//.test(ua) ? 'Edge' : /Firefox\//.test(ua) ? 'Firefox' : /Chrome\//.test(ua) ? 'Chrome' : /Safari\//.test(ua) ? 'Safari' : 'Other';
|
||||
const params = new URLSearchParams(location.search);
|
||||
event('page_view', { screen: 'home', metadata: {
|
||||
device: /iPad|Tablet/.test(ua) || (/Macintosh/.test(ua) && navigator.maxTouchPoints > 1) ? 'tablet' : /Mobi|Android|iPhone/.test(ua) ? 'mobile' : 'pc',
|
||||
browser: `${name}${browser ? ` ${browser[1]}` : ''}`, referrer: document.referrer,
|
||||
source: params.get('utm_source'), medium: params.get('utm_medium'), campaign: params.get('utm_campaign'), invitation: params.has('room'),
|
||||
} });
|
||||
}
|
||||
},
|
||||
screen(screen: string) { event('screen_view', { screen }); },
|
||||
click(action: string) { const flowId = crypto.randomUUID(); event('ui_action', { action, flowId }); return flowId; },
|
||||
error(code: string, screen: string) { event('client_error', { code, screen }); },
|
||||
gameStarted(matchId: string, mode: string) { event('game_started', { matchId, mode }); },
|
||||
gameFinished(matchId: string, mode: string, outcome: string, reason: string) { event('game_finished', { matchId, mode, outcome, reason }); },
|
||||
destroy() { destroyed = true; clearInterval(heartbeat); clearInterval(retry); document.removeEventListener('visibilitychange', activity); queue.clear(); },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { GAME_SESSION_STORAGE_KEY } from '../../constants/recording.js';
|
||||
import type { ActiveMatch, GameSessionState, PeerRoom, RecordAck, ResultSubmission } from '../shared/p2p-types.js';
|
||||
import type { Ack, Mode } from '../shared/types.js';
|
||||
import type { SessionStorage } from './result-reporter.js';
|
||||
|
||||
export class ApiClientError extends Error {
|
||||
constructor(public status: number, public code: string, message: string) { super(message); }
|
||||
}
|
||||
interface SavedSession { createRequestId: string; session?: GameSessionState; roomRequestId?: string; leavingRoomId?: string }
|
||||
export function createGameApi(storage: SessionStorage, send: typeof fetch = globalThis.fetch.bind(globalThis)) {
|
||||
let saved: SavedSession | undefined;
|
||||
const persist = () => storage.setItem(GAME_SESSION_STORAGE_KEY, JSON.stringify(saved));
|
||||
const session = (): GameSessionState => { if (!saved?.session) throw new Error('SESSION_NOT_READY'); return saved.session; };
|
||||
async function request<T>(path: string, method: 'GET' | 'POST' = 'GET', body?: unknown, authenticated = true): Promise<T> {
|
||||
const headers: Record<string, string> = { 'content-type': 'application/json' };
|
||||
if (authenticated) { headers.authorization = `Bearer ${session().token}`; headers['x-game-session'] = session().gameSessionId; }
|
||||
const response = await send(path, { method, headers, ...(body === undefined ? {} : { body: JSON.stringify(body) }) });
|
||||
const result = await response.json() as Ack<T>;
|
||||
if (!result.ok) throw new ApiClientError(response.status, result.code, result.message);
|
||||
if (!response.ok) throw new ApiClientError(response.status, 'SERVER_ERROR', '서버에 연결하지 못했습니다.');
|
||||
return result.data;
|
||||
}
|
||||
async function sync() {
|
||||
const guestToken = saved?.session?.guestToken;
|
||||
saved!.session = { ...await request<GameSessionState>('/api/game-session'), ...(guestToken ? { guestToken } : {}) };
|
||||
persist(); return session();
|
||||
}
|
||||
async function leaveRoom(id: string) {
|
||||
session(); saved!.leavingRoomId = id; persist();
|
||||
try { await request('/api/rooms/' + encodeURIComponent(id) + '/leave', 'POST', { requestId: crypto.randomUUID() }); }
|
||||
catch (error) { if (!(error instanceof ApiClientError) || ![404, 410].includes(error.status)) throw error; }
|
||||
delete saved!.leavingRoomId; persist();
|
||||
}
|
||||
return {
|
||||
get session() { return session(); },
|
||||
async ensureSession(guestToken?: string) {
|
||||
if (!saved) {
|
||||
const raw = storage.getItem(GAME_SESSION_STORAGE_KEY);
|
||||
if (raw) { try { saved = JSON.parse(raw) as SavedSession; } catch { /* recover invalid tab storage */ } }
|
||||
if (!saved?.createRequestId) {
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(32));
|
||||
const createRequestId = btoa(String.fromCharCode(...bytes)).replaceAll('+', '-').replaceAll('/', '_').replaceAll('=', '');
|
||||
saved = { createRequestId }; persist();
|
||||
}
|
||||
}
|
||||
if (saved.session) return sync();
|
||||
saved.session = await request<GameSessionState>('/api/game-sessions', 'POST', { createRequestId: saved.createRequestId, guestToken }, false);
|
||||
persist(); return session();
|
||||
},
|
||||
sync, request, leaveRoom,
|
||||
async resumeRoomLeave() { if (!saved?.leavingRoomId) return false; await leaveRoom(saved.leavingRoomId); return true; },
|
||||
async createRoom() {
|
||||
session(); saved!.roomRequestId ??= crypto.randomUUID(); persist();
|
||||
const room = await request<PeerRoom>('/api/rooms', 'POST', { requestId: saved!.roomRequestId });
|
||||
delete saved!.roomRequestId; persist(); return room;
|
||||
},
|
||||
async registerMatch(input: { requestId: string; matchId: string; mode: Mode; roomId?: string; expectedPreviousSeq: number }) {
|
||||
const active = await request<ActiveMatch>('/api/game-session/matches', 'POST', input);
|
||||
session().activeMatch = active; persist(); return active;
|
||||
},
|
||||
async record(input: ResultSubmission) {
|
||||
const ack = await request<RecordAck>('/api/game-session/results', 'POST', input);
|
||||
session().stats = ack.stats; session().lastAppliedSeq = Math.max(session().lastAppliedSeq, input.seq);
|
||||
if (session().activeMatch?.matchId === input.matchId) delete session().activeMatch;
|
||||
persist(); return ack;
|
||||
},
|
||||
};
|
||||
}
|
||||
export type GameApi = ReturnType<typeof createGameApi>;
|
||||
@@ -0,0 +1,474 @@
|
||||
import type { ActionId } from '../shared/types.js';
|
||||
import { COSMETIC_COLORS, SKIN_TONES, type EquipmentId } from '../../constants/cosmetics.js';
|
||||
import { normalizeAppearance, type FighterAppearance } from '../shared/cosmetics.js';
|
||||
|
||||
function equipped(appearance: FighterAppearance | undefined, item: EquipmentId) {
|
||||
return !appearance || !!appearance.equipment?.includes(item);
|
||||
}
|
||||
|
||||
function fighterPalette(index: number, appearance?: FighterAppearance) {
|
||||
const palette = (appearance?.characterId ?? index) === 1
|
||||
? '--skin:#b59173;--skin-shade:#786650;--skin-light:#dcc0a0;--suit:#455862;--suit-dark:#293840;--trim:#d7d5bf;--hair:#d7d9cf;--hair-shadow:#7e8d90'
|
||||
: '--skin:#d7ad8a;--skin-shade:#996b55;--skin-light:#f0cdaa;--suit:#aa5740;--suit-dark:#613b33;--trim:#d3bf99;--hair:#242b30;--hair-shadow:#454e53';
|
||||
if (!appearance) return palette;
|
||||
const selected = normalizeAppearance(appearance);
|
||||
const color = COSMETIC_COLORS.find(color => color.id === selected.colorId) ?? COSMETIC_COLORS[0];
|
||||
const skin = SKIN_TONES.find(tone => tone.id === selected.skinTone) ?? SKIN_TONES[0];
|
||||
return palette
|
||||
.replace(/--skin:[^;]+;--skin-shade:[^;]+;--skin-light:[^;]+;/,
|
||||
`--skin:${skin.skin};--skin-shade:${skin.shade};--skin-light:${skin.light};`)
|
||||
.replace(/--suit:[^;]+;--suit-dark:[^;]+;--trim:[^;]+;/,
|
||||
`--suit:${color.suit};--suit-dark:${color.suitDark};--trim:${color.trim};`)
|
||||
.replace(/--hair:[^;]+;--hair-shadow:[^;]+/,
|
||||
selected.hairStyle === 'white' ? '--hair:#d7d9cf;--hair-shadow:#7e8d90' : '--hair:#242b30;--hair-shadow:#454e53');
|
||||
}
|
||||
|
||||
/** Shared head drawing: full-body art and the hit close-up always depict the same fighter. */
|
||||
function fighterHead(index: number, includeNeck = true, appearance?: FighterAppearance) {
|
||||
const veteran = index === 1;
|
||||
const hairStyle = appearance ? normalizeAppearance(appearance).hairStyle : veteran ? 'white' : 'black';
|
||||
const bald = hairStyle === 'bald', whiteHair = hairStyle === 'white';
|
||||
const headband = appearance ? equipped(appearance, 'headband') : !veteran;
|
||||
const eyewear = appearance?.equipment?.includes('sunglasses') ? 'sunglasses'
|
||||
: appearance?.equipment?.includes('glasses') ? 'glasses' : undefined;
|
||||
return `<g class="portrait" stroke="#232a2c" stroke-width="1.8" stroke-linejoin="round">
|
||||
${bald ? '' : whiteHair ? '<path d="m110 36-7-12 14 2 7-14 9 11 20-7-5 11 15 4-7 26-43 4Z" fill="var(--hair)"/><path d="m146 29 10 3-4 22-14-7Z" fill="var(--hair-shadow)"/>' : '<path d="m113 33-15-7 14-3-1-13 16 9 16-14 2 16 19-5-9 12 11 8-15 27-40-6Z" fill="var(--hair)"/><path d="M109 29 103 35 100 44 109 39 113 32Z" fill="var(--hair)"/>'}
|
||||
${includeNeck ? '<path d="m116 66-3 17 14 12 24-12-9-20Z" fill="var(--skin)"/>\n <path d="m118 69 21-5 9 16-20 3-5 8-6-10Z" fill="var(--skin-shade)" stroke="none"/>' : ''}
|
||||
<path d="m113 44-6-3-2 9 7 9m39-14 7-5 2 8-7 10" fill="var(--skin)"/>
|
||||
<path${bald ? ' class="bald-scalp"' : ''} d="${bald
|
||||
? veteran ? 'M110 45C108 29 116 20 130 20C145 19 154 29 153 44L151 60 142 74 127 78 114 66Z' : 'M111 47C109 30 117 21 131 21C145 21 154 32 152 47L152 57 144 72 130 77 116 67Z'
|
||||
: veteran ? 'm113 33 19-6 21 8-2 25-9 14-15 4-13-12-4-20Z' : 'm113 34 18-9 20 10 1 22-8 15-14 5-14-10-5-20Z'}" fill="var(--skin)"/>
|
||||
${bald ? '<path d="M117 33Q124 23 135 26" stroke="var(--skin-light)" stroke-width="3" fill="none"/>' : ''}
|
||||
<path d="m143 34 7 3 1 18-8 16-13 6-8-7 14-3 6-11-4-9Z" fill="var(--skin-shade)" stroke="none"/>
|
||||
<path d="m116 52 5 7-5 3m27-10-4 9 7-2m-18-12-3 11 6 1" fill="none" stroke-width="1.3"/>
|
||||
${bald ? '' : whiteHair ? '<path d="m111 42-2-12 18-10 25 4 7 8-11-1-12 8 2-9-11 10-5-4-6 10Z" fill="var(--hair)"/><path d="m121 24 8 4 14-3m-6 8 13-5" fill="none" stroke="var(--hair-shadow)"/>' : '<path d="m109 43 1-17 18-7 25 7 5 15-14-10-2 14-10-11-8 12-1-14-8 12Z" fill="var(--hair)"/><path d="m116 24 9-3m13 4 11 3" stroke="var(--hair-shadow)" fill="none"/>'}
|
||||
${veteran ? '<path d="m118 68 5 3m0-1 3 3m7-1 2 2m3-5 2 1m3-4 2 1" fill="none" stroke-width="1"/>' : ''}
|
||||
${headband ? '<path d="m111 34 42-2-1 6-40 3Z" fill="#864237"/><path d="m111 37-19 8-19-5 13 13 24-11-12 18 17-15Z" fill="#864237"/>' : ''}
|
||||
<g class="face face-ready">
|
||||
<path d="m114 47 12 3-9 3Zm23 3 12-5-3 8Z" fill="#f0eee2" stroke-width="1.3"/>
|
||||
<path d="m121 49 1 3m21-4 1 3" stroke-width="2.6"/><path d="m114 44 12 4m12-1 12-5" stroke-width="2.5"/>
|
||||
<path d="m123 65 13-1" fill="none"/><path d="m117 56 5 1m19-1 5-2" fill="none" stroke-width=".8"/>
|
||||
</g>
|
||||
<g class="face face-focus">
|
||||
<path d="m113 48 13 3-10 2Zm23 2 13-5-4 8Z" fill="#f4f2e9" stroke-width="1.4"/>
|
||||
<path d="m122 50v2m21-4v3m-30-8 13 6m12-1 13-6" stroke-width="2.5"/>
|
||||
<path d="m122 63 16-1-3 6-11-1Z" fill="#ede6d4" stroke-width="1.2"/><path d="m128 64 5 1" fill="none" stroke-width=".7"/>
|
||||
</g>
|
||||
<g class="face face-win">
|
||||
<path d="m115 48 11 2m13-1 10-3" stroke-width="2.1"/><path d="m122 65 8 2 10-5" fill="none"/>
|
||||
<path d="m122 49v3m22-5v3" stroke-width="2.4"/>
|
||||
</g>
|
||||
<g class="face face-loss">
|
||||
<path d="m113 48 13 4m12-1 12-5m-28 18 14-1" fill="none" stroke-width="2.2"/>
|
||||
<path d="m116 55 5 4m18-4 5-3m-24 6 2 2m-1-3 2 2m18-4 3-1" fill="none" stroke-width=".8"/>
|
||||
</g>
|
||||
<g class="face face-draw">
|
||||
<path d="m114 47 12 3-10 3Zm24 3 11-3-3 6Z" fill="#ece9df" stroke-width="1.2"/><path d="m122 49v3m21-3v3" stroke-width="2.5"/>
|
||||
<path d="m124 65 12 1m-22-23 12 4m12 0 12-4" fill="none" stroke-width="1.5"/>
|
||||
</g>
|
||||
<g class="face face-shock">
|
||||
<path d="m112 44 15 3-3 10-9-2Zm25 4 16-8-2 13-11 4Z" fill="#fffdf1" stroke-width="1.6"/>
|
||||
<ellipse cx="122" cy="51" rx="1.9" ry="3" fill="#232a2c" stroke="none"/><ellipse cx="143" cy="50" rx="1.9" ry="3.2" fill="#232a2c" stroke="none"/>
|
||||
<path d="m112 40 14 3m11 0 16-8" fill="none" stroke-width="2.2"/>
|
||||
<path d="m121 63 15-3 5 11-10 5-10-5Z" fill="#33323a"/><path d="m123 63 11-1 1 3-11 2Z" fill="#faf4e7" stroke-width=".7"/>
|
||||
<path d="m114 57 5 3m-6-1 4 3m31-7 4-2m-4 5 4-2m-23-18 1 6m4-7 0 6" fill="none" stroke-width=".7"/>
|
||||
</g>
|
||||
${eyewear ? `<g class="eyewear eyewear-${eyewear}" stroke="#232a2c" stroke-width="2" stroke-linecap="round">
|
||||
<path d="M111 46 107 43M129 49Q132 47 136 49M153 43 158 40" fill="none"/>
|
||||
<path d="M111 43 129 46 127 59 113 57Z M135 46 154 39 153 55 138 59Z" fill="${eyewear === 'sunglasses' ? '#26343d' : '#d8f0f2'}" fill-opacity="${eyewear === 'sunglasses' ? '1' : '.14'}"/>
|
||||
<path d="m115 47 4 1m21 0 8-3" fill="none" stroke="#e4f1ec" stroke-width="1" opacity=".65"/>
|
||||
</g>` : ''}
|
||||
</g>`;
|
||||
}
|
||||
|
||||
/** Athletic ink-and-cel figures retain the arena's existing 260 × 300 geometry. */
|
||||
export function fighterArt(index: number, closeup = false, appearance?: FighterAppearance) {
|
||||
const characterId = appearance?.characterId ?? index;
|
||||
const veteran = characterId === 1;
|
||||
const shoes = equipped(appearance, 'shoes'), wristbands = equipped(appearance, 'wristbands');
|
||||
return `<svg class="fighter-art" viewBox="${closeup ? '72 10 136 130' : '0 0 260 300'}" fill="none" aria-hidden="true" style="${fighterPalette(index, appearance)}">
|
||||
<ellipse class="ground-shadow" cx="132" cy="285" rx="88" ry="7" fill="#222c3025"/>
|
||||
<g class="character" stroke="#232a2c" stroke-width="2.4" stroke-linejoin="round" stroke-linecap="round">
|
||||
<g class="legs legs-stance">
|
||||
<path d="m104 160-18 43-22 60 24 8 37-56 13-27 18 29 26 47 24-10-20-63-32-35Z" fill="var(--suit)"/>
|
||||
<path d="m105 166-8 40-23 51 15 7 27-53 18-24 5-23m3 15 24 37 19 40 10-5-20-58-20-31Z" fill="var(--suit-dark)" stroke="none"/>
|
||||
<path d="m111 179-12 34-13 11m39-24-16 20m42-29 10 26 18 15m-11 1 9 10" fill="none" stroke-width="1.5"/>
|
||||
${shoes ? `<path d="M66 263 88 271 83 280 59 279Z M184 263 203 255 207 280 185 281Z" fill="var(--skin)"/>
|
||||
<path d="M60 275Q69 280 85 276L83 289Q64 293 44 292Q39 292 41 287Q43 283 54 281Z M185 276Q195 281 207 276L211 282Q218 284 217 290Q211 294 182 292L178 287Z" fill="#30383c"/>
|
||||
<path d="M42 289Q60 291 82 287M182 289Q200 291 215 289" stroke-width="1"/>` : `<g class="bare-feet">
|
||||
<path d="M66 263 88 271 83 279Q84 285 80 289L48 291Q40 291 40 287Q40 284 46 283L57 279Q62 275 66 263Z M184 263 203 255 206 277Q209 281 216 284Q222 286 218 290Q215 292 208 291L185 290Q180 289 182 283L187 276Z" fill="var(--skin)"/>
|
||||
<path d="M61 280Q69 284 80 280M190 279Q198 283 206 280" stroke="var(--skin-shade)" stroke-width="1.4"/>
|
||||
<path d="m47 284 1 5m5-6 1 5m5-6 1 5m147-2-1 5m7-4-1 5m7-3-1 3" stroke-width="1"/>
|
||||
</g>`}
|
||||
</g>
|
||||
<g class="legs legs-airborne">
|
||||
<path d="m102 158-34 42-27 1-14 22 45 6 42-35 37 20 27-9-18-46Z" fill="var(--suit)"/>
|
||||
<path d="m116 175-46 41-29-3-6 10 37 6 42-35 34 19 19-6Z" fill="var(--suit-dark)" stroke="none"/>
|
||||
<path d="m146 191 30 12 14 42 22 7-7 23-32-17-19-34-27-14" fill="var(--suit)"/>
|
||||
${shoes ? `<path d="M192 249 211 253 207 270 190 265Z M43 203 43 225 32 225 30 204Z" fill="var(--skin)"/>
|
||||
<path d="M198 257Q203 263 213 261L216 276Q218 283 211 285L196 280Q188 277 188 271L190 264Z M32 204Q28 214 35 225L24 229Q19 230 18 223L16 210Q16 203 22 202Z" fill="#30383c"/>
|
||||
<path d="M194 276 208 282Q214 284 214 279M21 207 22 221Q22 226 27 226" stroke-width="1"/>` : `<g class="bare-feet">
|
||||
<path d="M192 249 211 253 207 266Q209 273 215 278Q219 284 214 286Q210 287 205 282L194 276Q188 273 189 267Z M43 203 43 225 32 224Q27 226 23 230Q18 232 16 227L15 213Q14 205 20 202L30 204Z" fill="var(--skin)"/>
|
||||
<path d="M195 267Q198 269 201 268M26 208Q24 215 28 221" stroke="var(--skin-shade)" stroke-width="1.3"/>
|
||||
<path d="m205 277-2 4m6-1-2 3m6 0-1 2M19 221l5-1m-5-4 4-1m-4-4 4-1" stroke-width="1"/>
|
||||
</g>`}
|
||||
<path d="m83 192-9 14-23 7m103-13 8 11m-1 14 7 14" stroke-width="1.5"/>
|
||||
</g>
|
||||
<path d="${veteran ? 'm110 77-28 9-5 20 18 28 8 34 50 4 16-41 10-27-22-24-17-4Z' : 'm113 77-26 10-7 21 20 26 3 34 49 4 13-41 7-30-25-23Z'}" fill="var(--suit)"/>
|
||||
<path d="m111 78 17 16 15-17 11 12-23 47-32-39Z" fill="var(--skin)"/>
|
||||
<path d="m112 89 12 12 4 18 10-14 9-24-8 0-11 12-11-13Z" fill="var(--skin-shade)" stroke="none"/>
|
||||
<path d="m111 102 12 3m10 3 7-6m-14 10 2 6" fill="none" stroke-width="1.2"/>
|
||||
${veteran ? '<path d="m90 86 21-8 6 8-17 30-7 36-9-48Z" fill="var(--suit-dark)"/><path d="m151 82 20 9-9 26-17-12Z" fill="var(--trim)"/><path d="m154 91 12 5m-15 2 12 5m-7 0 5 3" stroke="var(--suit-dark)" stroke-width="2"/>' : '<path d="m109 79-12 13 30 43 24-51-8-7-16 39Z" fill="var(--trim)"/><path d="m100 87 7 2m-9 3 7 3m37-10-9 22" fill="none" stroke-width="1.1"/>'}
|
||||
<path d="m155 112-12 32-6 17 19 2 6-40-2-12Zm-56 18 14 17 23 12-32 1Z" fill="var(--suit-dark)" stroke="none"/>
|
||||
<path d="m105 133 9 11m28-16-7 20m13-4-5 12m-34-7 16 6" fill="none" stroke-width="1.4"/>
|
||||
<path d="m101 155 55 4-3 12-50-4Z" fill="#303238"/><path d="m138 165 7 30 11 8 1-17-10-23-3 2-1 38-9 7-3-40Z" fill="${veteran ? '#9b5448' : '#484c4b'}"/>
|
||||
${fighterHead(characterId, true, appearance)}
|
||||
<g class="pose pose-ready">
|
||||
<path d="m88 90-14 5-18 28-5 17 15 9 29-27 8-14Z" fill="var(--skin)"/><path d="m75 102-15 29 4 10 21-17 8-18" fill="var(--skin-shade)" stroke="none"/>
|
||||
<path d="m54 137 20-21 12-19 13 7-10 23-24 23Z" fill="var(--skin)"/>${wristbands ? '<path d="m76 113 15-16 11 8-12 20Z" fill="var(--trim)"/>' : ''}
|
||||
<path d="m87 100 1-13 10-9 13 4 4 10-9 12-11 1Z" fill="var(--skin)"/><path d="m94 86 1 7m5-9 1 7m5-5-1 7m-7 8 6-4" stroke-width="1.2"/>
|
||||
<path d="m163 91 15 13 14 27-9 18-18-18-15-27Z" fill="var(--skin)"/><path d="m166 101 12 22 7 17-12-11-16-24" fill="var(--skin-shade)" stroke="none"/>
|
||||
<path d="m178 130 14-26 14 4-9 32-13 10-12-9Z" fill="var(--skin)"/>${wristbands ? '<path d="m187 110 3-12 18 5-4 16Z" fill="var(--trim)"/>' : ''}
|
||||
<path d="m190 100-3-12 7-9 16 3 5 9-8 15Z" fill="var(--skin)"/><path d="m194 84 0 8m6-8 0 9m6-6-1 8m-17-3 7 7" stroke-width="1.2"/>
|
||||
<path d="${wristbands ? 'm66 122 6-5m98 2 6 5m-93-6 10 4m-11 0 9 4m104-20 13 4m-14 1 13 4' : 'm66 122 6-5m98 2 6 5'}" fill="none" stroke-width="1"/>
|
||||
</g>
|
||||
<g class="pose pose-charge">
|
||||
<path d="m88 90-14 10-13 39 10 21 22-8-7-18 15-28Z M163 90l17 12 18 38-5 21-23-8 3-19-21-29Z" fill="var(--skin)"/>
|
||||
<path d="m74 111-6 25 8 11 6-15 10-24m75 0 16 28-5 11-4-17-15-19" fill="var(--skin-shade)" stroke="none"/>
|
||||
${wristbands ? '<path d="m71 143 18-2 4 15-20 4Zm107-3 19 1-3 18-20-5Z" fill="var(--trim)"/>' : ''}
|
||||
<path d="m74 157-1 14 10 8 14-7-2-16-12-3Zm102-3-4 16 12 9 12-6 1-14-12-7Z" fill="var(--skin)"/>
|
||||
<path d="${wristbands ? 'm78 159 0 8m6-10 0 9m5-9 0 8m89-7-1 8m7-6-1 8m6-5-1 7m-114-28 14-2m96 1 13 3' : 'm78 159 0 8m6-10 0 9m5-9 0 8m89-7-1 8m7-6-1 8m6-5-1 7'}" stroke-width="1.1"/>
|
||||
</g>
|
||||
<g class="pose pose-attack">
|
||||
<path d="m86 91-10 18 19 27 35 8 46-18-7-17-42 13-19-14-7-18Z" fill="var(--skin)"/>
|
||||
<path d="m84 112 20 18 24 7 40-17 1 7-37 17-37-8Z" fill="var(--skin-shade)" stroke="none"/>
|
||||
<path d="m156 89 25 12 29-2 6 20-38 6-23-12-9-15Z" fill="var(--skin)"/><path d="m167 111 17 7 25-6 3 7-34 6-17-7Z" fill="var(--skin-shade)" stroke="none"/>
|
||||
${wristbands ? '<path d="m195 100 18-4 7 22-21 4Zm-36 12 12-4 8 17-13 7Z" fill="var(--trim)"/>' : ''}
|
||||
<path d="m211 100 6-13 5-5 5 2-4 15 21-10 5 4-16 12 17-5 3 5-17 10 13-1 1 5-25 8-12-6Z" fill="var(--skin)"/>
|
||||
<path d="m172 115 12-10 9-1 2 5-12 9 14-4 3 5-20 12-9-6Z" fill="var(--skin)"/>
|
||||
<path d="${wristbands ? 'm197 104 17-4m-16 9 17-5m-16 10 18-5m-107 11 11 5m58-19 12-1m30 8 6-9' : 'm110 120 11 5m58-19 12-1m30 8 6-9'}" stroke-width="1.1"/>
|
||||
</g>
|
||||
<g class="pose pose-guard">
|
||||
<path d="m87 92-10 14 19 35 26-6 29-52-15-11-29 43-9-23Zm74 0 16 18-15 34-24-9-31-48 14-14 30 40 3-13Z" fill="var(--skin)"/>
|
||||
<path d="m83 107 20 25 14-2 5 6-26 5Zm78-6 9 13-12 21-7-7Z" fill="var(--skin-shade)" stroke="none"/>
|
||||
${wristbands ? '<path d="m121 91 14-23 20 12-15 23Zm-16-13 15-11 17 24-17 13Z" fill="var(--trim)"/>' : ''}
|
||||
<path d="m134 72 0-13 8-9 14 4 3 12-8 14Zm-31 5-6-11 4-13 15-3 8 12-5 11Z" fill="var(--skin)"/>
|
||||
<path d="${wristbands ? 'm141 57 0 9m5-10 0 8m-42-7 4 8m2-11 3 8m-3 17 12-7m-9 11 12-7m11 5 15 8m-17-4 13 7' : 'm141 57 0 9m5-10 0 8m-42-7 4 8m2-11 3 8'}" stroke-width="1"/>
|
||||
</g>
|
||||
<g class="pose pose-dodge">
|
||||
<path d="m88 91-18 14-22 24 11 14 27-15 14-25Z" fill="var(--skin)"/><path d="m76 109-20 23 5 5 23-15 8-18" fill="var(--skin-shade)" stroke="none"/>
|
||||
${wristbands ? '<path d="m48 125 15 14-9 13-17-14Z" fill="var(--trim)"/>' : ''}<path d="m38 136-13 4-3 12 12 9 12-4 8-10Z" fill="var(--skin)"/>
|
||||
<path d="m159 90 16 9 7-17-20-24-10 10 11 17-8 14Z" fill="var(--skin)"/><path d="m162 99 12 5 8-22-8-1-4 13Z" fill="var(--skin-shade)" stroke="none"/>
|
||||
${wristbands ? '<path d="m153 59 10-7 11 14-12 9Z" fill="var(--trim)"/>' : ''}<path d="m154 62-8-10-5-14 5-2 7 13 1-18 5 0 1 23 4 4-3 8Z" fill="var(--skin)"/>
|
||||
<path d="${wristbands ? 'm156 62 10-6m-7 10 9-6m-137 83 5 6m0-9 6 6' : 'm31 143 5 6m0-9 6 6'}" stroke-width="1.1"/>
|
||||
</g>
|
||||
<g class="pose pose-win">
|
||||
<path d="m88 92-14 15-8 38 16 7 13-41 5-9Zm72-2 18 11 14-20-10-8-16 11Z" fill="var(--skin)"/>
|
||||
<path d="m76 111-7 31 8 3 9-31m82-24 12 6 6-8" fill="var(--skin-shade)" stroke="none"/>
|
||||
${wristbands ? '<path d="m67 136 20 6-4 16-20-6Zm108-58 10-15 17 11-12 16Z" fill="var(--trim)"/>' : ''}
|
||||
<path d="m65 151-2 14 12 8 12-9-5-12Zm118-86 1-15 12-5 11 8-3 13-11 8Z" fill="var(--skin)"/>
|
||||
<path d="m189 53v8m6-10v9m5-6v8m-130 94v8m6-6v7" stroke-width="1.1"/>
|
||||
</g>
|
||||
<g class="pose pose-loss">
|
||||
<path d="m86 94-13 23-5 41 15 5 14-50Zm74-3 18 15 9 35-15 16-38-14 4-14 30 8-12-29Z" fill="var(--skin)"/>
|
||||
<path d="m78 115-6 36 7 4 9-40m77 29 11-3 8-3-10 15-34-12 1-6Z" fill="var(--skin-shade)" stroke="none"/>
|
||||
${wristbands ? '<path d="m68 150 18 4-3 15-17-4Zm63-23 17 5-7 17-14-6Z" fill="var(--trim)"/>' : ''}
|
||||
<path d="m67 164-2 15 8 8 11-5 0-13Zm65-35-12-6-16 3-3 7 8 9 19 2Z" fill="var(--skin)"/>
|
||||
<path d="m108 130 13 1m-12 5 10 1m-49 35v8m6-7v8" stroke-width="1.1"/>
|
||||
</g>
|
||||
<g class="pose pose-draw">
|
||||
<path d="m86 92-16 24 13 23 58-6-1-16-47 4 8-17Zm75 0 17 20-7 33-54-6 1-17 37 1-6-20Z" fill="var(--skin)"/>
|
||||
<path d="m76 117 11 14 43-3 1 5-48 6Zm79 15 13-7-2 15-41-4 1-8Z" fill="var(--skin-shade)" stroke="none"/>
|
||||
${wristbands ? '<path d="m121 117 20 0 3 17-20 1Zm-16 3 20 3-2 18-20-4Z" fill="var(--trim)"/>' : ''}
|
||||
<path d="m140 119 12-4 9 7-5 10-12 0Zm-35 1-11-4-10 5 4 12 15 3Z" fill="var(--skin)"/>
|
||||
${wristbands ? '<path d="m126 122h13m-12 6h13m-32-2 14 3m-15 2 13 3" stroke-width="1"/>' : ''}
|
||||
</g>
|
||||
<g class="pose pose-hit">
|
||||
<path d="m88 92-15-7-25-24-12 12 18 33 24 12 22-13Zm72 0 19-8 22 23 28-9 7 14-30 16-20-5-31-15Z" fill="var(--skin)"/>
|
||||
<path d="m46 75 15 24 22 7-5 10-24-10Zm132 18 19 22 27-8 5 6-23 15-20-5Z" fill="var(--skin-shade)" stroke="none"/>
|
||||
${wristbands ? '<path d="m34 66 12-15 16 14-15 18Zm185 32 12-5 9 22-15 6Z" fill="var(--trim)"/>' : ''}
|
||||
<path d="m37 58-10-3-13-15 4-4 13 11-7-18 5-3 11 21-2-21 5 0 7 27-4 9Zm194 36 8-13 5 2-4 16 12-5 3 5-11 13-6 5Z" fill="var(--skin)"/>
|
||||
${wristbands ? '<path d="m42 64 9-9m-5 13 9-9m169 45 11-5m-9 11 12-6" stroke-width="1.1"/>' : ''}
|
||||
</g>
|
||||
</g>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
/** Preserved Yamcha-reference variant; not used by the active finisher. */
|
||||
export function yamchaFallenFighterArt(index: number) {
|
||||
return `<svg class="fallen-fighter-art" data-fallen-index="${index}" viewBox="0 0 640 400" fill="none" aria-hidden="true" style="${fighterPalette(index)}">
|
||||
<g stroke="#343731" stroke-linejoin="round" stroke-linecap="round">
|
||||
<path d="m42 212 42-85 95-64 136-25 123 23 102 61 57 99-39 91-115 61-134 12-150-38-86-64Z" fill="#b9af93" stroke-width="3"/>
|
||||
<path d="m78 210 43-64 74-48 117-24 115 27 83 49 47 70-38 66-88 48-121 15-121-33-83-57Z" fill="#8c8974" stroke-width="2.5"/>
|
||||
<path d="m125 213 42-50 79-33 89-15 94 34 71 54-10 66-78 42-102 7-98-27-69-38Z" fill="#777c68" stroke-width="2"/>
|
||||
<g stroke-width="2">
|
||||
<path d="m51 214 59-6 51 18 38-13m-99-73 31 25 40-3 33 23m-28-111 22 48 30 6m77-79-6 47 19 25m75-68-15 46 19 29m87-16-33 21-13 38m91-18-33 39-36 2m67 59-52-16-35 15m13 70-28-40-21-3m-38 69-4-35-31-27m-61 65 8-37-23-19m-92 29 31-37-9-29m-79 26 40-34 30-2"/>
|
||||
<path d="m87 91 12-8 17 8-6 13-21-1ZM522 85l14-8 20 13-10 14-20-3ZM50 292l21-8 15 19-27 7ZM498 344l20-15 23 8-2 21-29 4ZM130 355l19-23 27-2 19 16-7 16-34 7Z" fill="#c4b89c"/>
|
||||
<path d="m146 337 12 14 25-7m-26 9-3 14m-92-75 9 9m465-213 9 8m-32 248 11 8" stroke-width="1.3"/>
|
||||
</g>
|
||||
<g fill="#dbd1b7" stroke="#747664" stroke-width="1.5">
|
||||
<path d="M145 87q-13-7-6-18-4-15 10-17 6-13 17-5 15-2 14 14 9 12-5 19-8 14-20 5Z"/>
|
||||
<path d="M482 123q-10-6-5-16-2-10 9-12 6-10 14-3 12 2 8 13 8 11-4 16-11 12-22 2Z"/>
|
||||
<path d="M457 62q-7-7 0-13 7-5 12 1 6 9-4 14Z"/>
|
||||
</g>
|
||||
<g stroke="#525b4c" stroke-width="1.3">
|
||||
<path d="m173 206-36-5m45 13-42-3m38 13-30-1m29 12-28 3m325-60 28 4m-26 4 35 4m-33 4 27 4m-25 52 31 9m-36-1 24 9m-232 42-24 12m32-6-23 13m111-226-11-15m22 9-4-18"/>
|
||||
</g>
|
||||
<g class="fallen-pose-trace" transform="translate(130 -100) scale(1.2)" fill="none" stroke="#242b2b" stroke-width="1.6" stroke-linejoin="round" stroke-linecap="round">
|
||||
<path d="M49 291 64 272 86 267 112 242 165 235 196 256 206 291 218 328 215 356 199 359 183 338 149 331 128 321 111 334 96 327 94 308 76 304 52 301Z" fill="#333c30" stroke="none" opacity=".55"/>
|
||||
<!-- The hidden right arm terminates here; its shoulder has no visible contour. -->
|
||||
<path d="M112 259 102 277 88 290 75 290 72 279 87 272 98 256Z" fill="var(--skin-shade)"/>
|
||||
<path d="m86 271 13 4-7 15-14 5-6-10Z" fill="var(--trim)"/>
|
||||
<path d="m82 278 10 3m-12 2 10 3" stroke-width="1"/>
|
||||
<path d="M77 281 67 277 55 280 46 290 49 297 63 299 74 296 81 289Z" fill="var(--skin)"/>
|
||||
<path d="m54 283 3 6m3-9 3 6m3-7 3 6m-5 7 8-3" stroke-width="1.1"/>
|
||||
<path d="m49 293 13 2 10-4 9-2-7 7-11 3-14-2Z" fill="var(--skin-shade)" stroke="none"/>
|
||||
<!-- The far thigh disappears beneath the abdomen and the central forward sole. -->
|
||||
<path d="M130 252 114 257 108 269 106 281 98 294 100 307 114 313 130 299 147 284 159 269Z" fill="var(--suit)"/>
|
||||
<path d="M119 262 115 277 120 286 111 300 102 302 100 307 114 313 136 293 147 284 138 273Z" fill="var(--suit-dark)" stroke="none"/>
|
||||
<path d="m111 269 6-7m-9 18 7-8m3 3 5-11m-20 29 7-5" stroke-width="1.2"/>
|
||||
<!-- A low back and pelvis share one contour; the knee interrupts its lower edge. -->
|
||||
<path d="M118 226 130 219 146 219 162 222 177 225 188 231 195 241 199 255 202 269 199 280 191 289 179 296 163 295 147 288 136 275 128 264 119 259 111 249Z" fill="var(--suit)"/>
|
||||
<path d="M180 228 190 239 194 254 195 268 188 279 173 284 151 281 159 289 179 296 191 289 199 280 202 269 199 255 195 241 188 231Z" fill="var(--suit-dark)" stroke="none"/>
|
||||
${index === 1
|
||||
? '<path d="M124 229 136 223 148 226 139 240 143 261 133 253 124 241Z" fill="var(--suit-dark)" stroke="none"/><path d="M169 226 182 230 191 241 187 253 174 242 163 236Z" fill="var(--trim)"/><path d="m172 234 12 8m-11-3 10 7" stroke="var(--suit-dark)" stroke-width="1.5"/>'
|
||||
: '<path d="M119 228 131 222 150 223 170 226 185 233 182 237 167 232 149 229 134 229 124 235Z" fill="var(--trim)"/><path d="m139 225 10 1m17 3 12 5" stroke-width="1"/>'}
|
||||
<path d="m140 238 8 16 11 9m22-15 1 13-12 9-12 1m-17-8 7 11m39-4 9-4" stroke-width="1.3"/>
|
||||
<!-- Short rear hair retains the tucked head position and never exposes the face. -->
|
||||
<path d="M111 218 122 215 139 217 147 223 138 231 121 233 111 228Z" fill="var(--skin-shade)"/>
|
||||
${index === 1
|
||||
? '<path d="M76 236 77 224 71 214 84 216 93 202 101 211 119 204 116 213 135 213 130 222 139 226 128 236 111 238 97 242 85 240Z" fill="var(--hair)"/><path d="M116 216 130 218 128 231 113 236 101 237 110 227Z" fill="var(--hair-shadow)" stroke="none"/><path d="m82 222 13-7 12 3-6 8m5-9 11-5m-26 16 10-6" stroke="var(--hair-shadow)" stroke-width="1.4"/>'
|
||||
: '<path d="M75 237 78 225 70 217 84 218 83 206 97 212 107 198 111 211 128 204 123 215 138 213 132 223 140 227 127 237 111 239 96 243 83 241Z" fill="var(--hair)"/><path d="M98 215 107 208 106 222 117 216 112 229 125 223 119 234 106 237 91 239 99 227 86 231Z" fill="var(--hair-shadow)" stroke="none"/><path d="M77 233 92 229 109 225 132 222 134 226 111 229 94 233 79 237Z" fill="#864237"/><path d="M80 234 68 234 63 230 67 239 78 238 72 245 83 239Z" fill="#864237" stroke-width="1.2"/>'}
|
||||
<!-- This broad bent arm masks the face and cuts off the lower hair contour. -->
|
||||
<path d="M122 226 112 230 100 232 86 234 75 238 68 244 64 251 62 259 68 265 78 268 89 268 104 262 118 254 126 247 132 234Z" fill="var(--skin)"/>
|
||||
<path d="M63 256 68 260 80 262 91 261 107 254 119 244 125 233 132 234 126 247 118 254 104 262 89 268 78 268 68 265 62 259Z" fill="var(--skin-shade)" stroke="none"/>
|
||||
<path d="M119 226 131 222 142 225 148 231 137 237 127 247 119 245 126 235Z" fill="var(--suit)"/>
|
||||
<path d="M119 226 124 225 130 235 123 245 119 245 126 235Z" fill="${index === 1 ? 'var(--suit-dark)' : 'var(--trim)'}"/>
|
||||
<path d="m109 248-9 7-10 3m-14-12-3 8m52-24 6 3" stroke-width="1.2"/>
|
||||
<path d="M72 233 76 228 82 228 88 232 86 235 77 239 70 241Z" fill="var(--trim)"/>
|
||||
<path d="m75 231 5 5m-8-2 5 4" stroke-width="1"/>
|
||||
<!-- The right boot is a narrow side view behind the folded right knee. -->
|
||||
<path d="M188 309 201 312 209 318 213 330 206 348 196 350 188 337 184 321Z" fill="#30383c"/>
|
||||
<path d="m190 314 9 2 6 6m-16-3 9 2 6 6" stroke="var(--trim)" stroke-width="2.5"/>
|
||||
<path d="M208 315 213 319 216 326 217 339 212 352 206 356 197 355 194 346 195 334 200 323Z" fill="#30383c"/>
|
||||
<path d="M207 320 201 330 198 341 200 350 205 352 211 339 211 330Z" fill="#454e53" stroke="none"/>
|
||||
<path d="M209 319 213 327 214 338 209 350 205 353 199 352" stroke="#b5b4a5" stroke-width="1.5"/>
|
||||
<path d="m202 330 7 5m-10 7 7 4" stroke-width="1.2"/>
|
||||
<!-- Folded thighs overlap the abdomen without an elongated exposed shin. -->
|
||||
<path d="M148 284q11-7 25-4l13 5 7 8-3 8 7 5 2 11-5 10-9 4-9 7-5-6-4 5-6-8-10-3-9-8-6-9-1-12Z" fill="var(--suit)"/>
|
||||
<path d="M146 295 153 287 163 284 177 286 187 291 174 289 162 290 154 297 147 311 151 319 159 324 171 329 171 334 167 337 161 329 151 326 142 318 136 309 135 297Z" fill="var(--suit-dark)" stroke="none"/>
|
||||
<path d="m154 299 8-5 12 0 13 3m-34 13 10-5 10 2m-14 15 15 6 15-8" stroke-width="1.3"/>
|
||||
<!-- The large central sole overlaps the folded leg and the knee's left edge. -->
|
||||
<path d="M123 277 135 279 143 287 145 297 137 304 125 313 110 316 106 307 113 291Z" fill="#30383c"/>
|
||||
<path d="m122 280 9 2 10 10m-21-8 9 2 10 10" stroke="var(--trim)" stroke-width="2.5"/>
|
||||
<path d="M131 283 137 283 141 289 142 296 135 307 126 316 118 328 112 331 104 328 100 322 102 312 110 300 120 288Z" fill="#30383c"/>
|
||||
<path d="M130 287 136 287 139 292 137 299 128 308 120 319 114 327 108 327 103 322 105 313 113 301 122 291Z" fill="#454e53" stroke="none"/>
|
||||
<path d="M132 286 137 289 139 295 132 305 123 314 115 326 111 328 105 325 103 321" stroke="#b5b4a5" stroke-width="1.5"/>
|
||||
<path d="m122 295 10 10m-17 0 9 10m-16 0 8 8" stroke-width="1.2"/>
|
||||
</g>
|
||||
<g stroke="#4a5547" stroke-width="1.2">
|
||||
<path d="m181 192 14 2m-19 4 16 2m-18 5 15 2m120 119 12 3m-16 3 15 4m-18 3 14 3m126-99 22 3m-20 3 25 4m-23 3 17 4m-27 83 20 3m-21 4 16 3"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
/** Slumped against a rock face, with loose arms and legs extending along the ground. */
|
||||
function fallenFighterArt(index: number, appearance?: FighterAppearance) {
|
||||
const characterId = appearance?.characterId ?? index;
|
||||
const shoes = equipped(appearance, 'shoes'), wristbands = equipped(appearance, 'wristbands');
|
||||
return `<svg class="fallen-fighter-art rock-fallen-art" data-fallen-index="${index}" viewBox="0 0 640 400" fill="none" aria-hidden="true" style="${fighterPalette(index, appearance)}">
|
||||
<g stroke="#343731" stroke-width="2" stroke-linejoin="round" stroke-linecap="round">
|
||||
<path d="M-1200 276 100 272 201 278 294 273 419 282 1800 270V400H-1200Z" fill="#c4b89a" stroke="none"/>
|
||||
<path d="M0 276 100 272 201 278 294 273 419 282M29 321l151-17 90 1m-192 38 145-22 57-1m-151 40 126-27" stroke="#8c8771" stroke-width="1.3"/>
|
||||
<!-- A tall rock face supports the shoulder; the open ground runs off to the left. -->
|
||||
<path d="M393 287 422 241 438 171 456 116 473 84 487 23 524-14 579-9 602 61 630 103 658 129 790 20 960 64 1150-60 1800-80V321L740 323 638 310 538 305 468 319Z" fill="#a59f86" stroke-width="3"/>
|
||||
<path d="m438 171 18-55 17-32 14-61 37-37 12 91-36 77-15 72-28 52-64 9 29-46Z" fill="#c8bea1" stroke="none"/>
|
||||
<path d="M536 77 579-9 602 61 630 103 594 143 607 222 680 281 790 20 745 211 850 298 960 64 950 278 1150-60 1110 300 1800 321 740 323 638 310 538 305 468 319 495 273 498 209 524 155Z" fill="#858571" stroke="none"/>
|
||||
<path d="m487 23 25 52-26 64 14 15m36-77-12 78-26 54-3 64-27 46m126-176-32 26 9 64-33 72m-92-134 21 30-13 43m59-107 15 37-12 34m-35 65 14 11 23-5m40-180 8 26-10 34"/>
|
||||
<path d="m432 190 22-15 10-27m-23 66 26-8 15 13m-46 14 15 18-4 24m-11-65 8-16 13-2" stroke="#595e50" stroke-width="1.5"/>
|
||||
<path d="m447 125 8-21m-2 37 9-26m25 64 6-18m-10 31 6-19m64 64 9-22m-7 39 9-24m-14 39 8-17" stroke="#686e5c" stroke-width="1.2"/>
|
||||
<path d="M155 296q99-27 231-24l62 19-41 27-108 33-72-3 38-25-110-12Z" fill="#4a5143" stroke="none" opacity=".3"/>
|
||||
<g stroke="#242b2b" stroke-width="2.5">
|
||||
<!-- Far arm hangs alongside the rock instead of supporting the body. -->
|
||||
<path d="m412 181 15 6 11 35-5 30-8 22-11-3 6-34-13-30Z" fill="var(--skin)"/>
|
||||
<path d="m423 195 9 28-6 30-6 14 5 7 8-22 5-30-11-35Z" fill="var(--skin-shade)" stroke="none"/>
|
||||
${wristbands ? '<path d="m416 259 13 4-5 15-13-4Z" fill="var(--trim)"/>' : ''}
|
||||
<path d="m412 274 12 3-3 13-6 6-4-3 2-9-7 7-4-4 4-8Z" fill="var(--skin)"/>
|
||||
<!-- The far leg lies almost horizontally; its shoe is seen from the side. -->
|
||||
<path d="M377 244 391 260 361 281 313 285 264 289 216 287 213 269 264 268 307 265 348 250Z" fill="var(--suit)"/>
|
||||
<path d="m217 280 46 2 49-5 45-5 28-17 6 5-30 21-48 4-49 4-48-2Z" fill="var(--suit-dark)" stroke="none"/>
|
||||
<path d="m314 269-15 9m-29-5-12 7m81-15 15-6" stroke-width="1.5"/>
|
||||
${shoes ? `<path d="M214 270 216 286 199 289 197 273Z" fill="var(--skin)"/>
|
||||
<path d="M199 273Q204 281 201 289L170 292Q158 293 155 287Q153 282 161 279L184 276Z" fill="#30383c"/>
|
||||
<path d="M158 288Q164 290 172 289L200 286" stroke-width="1"/>` : `<g class="bare-feet">
|
||||
<path d="M214 270 216 286 199 289 175 292Q160 294 157 289Q153 285 159 282L182 278Q191 277 198 273Z" fill="var(--skin)"/>
|
||||
<path d="M195 281Q198 284 200 283M161 283l2 6m5-7 2 7m5-8 2 7" stroke-width="1"/>
|
||||
</g>`}
|
||||
<!-- The pelvis rests on the ground while the upper back leans into the rock. -->
|
||||
<path d="M371 180 389 169 409 174 426 188 419 218 404 252 381 267 353 249 362 216 359 194Z" fill="var(--suit)"/>
|
||||
<path d="m409 177 11 15-13 39-26 20-21-4-7 2 28 18 23-15 15-34 7-30Z" fill="var(--suit-dark)" stroke="none"/>
|
||||
<path d="M377 165 393 165 400 179 391 193 375 182Z" fill="var(--skin)"/>
|
||||
<path d="m377 169 15 3 6 8-7 9-12-10Z" fill="var(--skin-shade)" stroke="none"/>
|
||||
${characterId === 1
|
||||
? '<path d="m363 190 8-10 7 13-10 15-8-6Zm44-14 14 8 5 10-13 10-11-8Z" fill="var(--trim)"/>'
|
||||
: '<path d="m370 181 6-5 13 16 14-16 7 4-20 22Z" fill="var(--trim)"/>'}
|
||||
<path d="m375 214 12 11 13-5m-35 15 18 4-9 9m29-44 8-9" stroke-width="1.5"/>
|
||||
<!-- Knee, shin and shoe share one diagonal instead of turning outward at the ankle. -->
|
||||
<path d="M377 248 397 258 374 291 351 313 325 345 304 334 327 298 346 273 356 253Z" fill="var(--suit)"/>
|
||||
<path d="m381 258-18 29-27 26-21 26 10 6 26-32 23-22 23-33-10-5Z" fill="var(--suit-dark)" stroke="none"/>
|
||||
<path d="m349 279 12-6m-30 33 13-13m-14 32 9-13m28-49 9 5" stroke-width="1.5"/>
|
||||
${shoes ? `<path d="M305 335 324 345 315 359 295 347Z" fill="var(--skin)"/>
|
||||
<path d="M296 348Q302 356 316 358L305 374Q301 380 286 384Q280 386 274 379Q272 376 277 370Z" fill="#30383c"/>
|
||||
<path d="M276 378Q280 382 286 381L300 375 312 361" stroke-width="1"/>` : `<g class="bare-feet">
|
||||
<path d="M305 335 324 345 313 361 303 373Q296 379 285 383Q278 386 274 381Q271 378 276 372L294 351Z" fill="var(--skin)"/>
|
||||
<path d="M296 350Q300 355 305 355M277 374l5 5m1-10 6 7m0-12 6 8" stroke-width="1"/>
|
||||
</g>`}
|
||||
<!-- Right arm follows the user's short, slack outline with the same width as the far arm. -->
|
||||
<path d="M351 201 364 209 351 227 337 236 323 247 311 241 327 226 338 215Z" fill="var(--skin)"/>
|
||||
<path d="M356 209 346 223 333 234 317 244 323 247 337 236 351 227 364 209 359 206Z" fill="var(--skin-shade)" stroke="none"/>
|
||||
<path d="M372 182Q361 184 356 193L350 203 362 211 376 197 380 188Z" fill="var(--suit)"/>
|
||||
<path d="M350 201 363 207 374 196 376 199 363 212 348 205Z" fill="var(--trim)"/>
|
||||
${wristbands ? '<path d="m317 230 13 10-7 7-13-10Z" fill="var(--trim)"/>' : ''}
|
||||
<path d="m311 239 12 7-5 6-12 0-5-3 1-4 5-2Z" fill="var(--skin)"/>
|
||||
<path d="m312 247-5 2m10 0-5 2m35-35-6 8" stroke-width="1.2"/>
|
||||
<g class="rock-fallen-head" transform="translate(384 150) rotate(16) scale(.86) translate(-130 -52)">${fighterHead(characterId, false, appearance)}</g>
|
||||
</g>
|
||||
<g fill="#b4ac90" stroke="#676d5b" stroke-width="1.5">
|
||||
<path d="m438 288 16-11 20 13-11 13-22-3Zm-318 28 12-5 9 8-7 6-15-2Zm258 37 12-9 16 5-3 11-20 2Z"/>
|
||||
<path d="m454 279 2 12 17-1m-341 25 1 8"/>
|
||||
</g>
|
||||
<path d="m95 299 45-4m-23 38 37-6m20-21 19-2m173 24 18-4m-7 46 47-4m-54 14 28-3" stroke="#767b65" stroke-width="1.2"/>
|
||||
<path d="M82 298q17-10 33-2m-2 8q22-9 39-4m29 25q20-9 41-5" stroke="#e8dcc0" stroke-width="7" opacity=".7"/>
|
||||
</g>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
function beamFinisherArt(hitIndex: 0 | 1, appearances?: [FighterAppearance, FighterAppearance]) {
|
||||
const attacker = 1 - hitIndex;
|
||||
const victim = appearances?.[hitIndex];
|
||||
const maskId = `beam-remnant-${hitIndex}`;
|
||||
const reactionMaskId = `beam-reaction-${hitIndex}`;
|
||||
return `<div class="cutscene-page cutscene-beam-page" aria-hidden="true">
|
||||
<div class="cutscene-panel cutscene-attack" data-panel="attack">
|
||||
<div class="cutscene-ink-strokes"></div>
|
||||
<div class="fighter cutscene-closeup" data-fighter="${attacker}" data-action="beam"><div class="character-direction">${fighterArt(attacker, true, appearances?.[attacker])}</div></div>
|
||||
</div>
|
||||
<div class="cutscene-panel cutscene-impact beam-impact" data-panel="impact">
|
||||
<div class="beam-rush-lines"></div>
|
||||
<div class="fighter beam-impact-victim" data-fighter="${hitIndex}" data-hit="true"><div class="character-direction">${fighterArt(hitIndex, false, appearances?.[hitIndex])}</div></div>
|
||||
<div class="beam-launch" data-caster="${attacker}">
|
||||
<div class="fighter beam-caster" data-fighter="${attacker}" data-action="beam"><div class="character-direction">${fighterArt(attacker, false, appearances?.[attacker])}</div></div>
|
||||
<svg class="beam-ray" viewBox="0 0 260 300" fill="none" aria-hidden="true">
|
||||
<path d="M182 122Q195 103 225 99L5000-3400V3800L213 138Q191 140 182 122Z" fill="#89d7e6" stroke="#477d90" stroke-width="2"/>
|
||||
<path d="M193 119 225 103 5000-3100V3500L215 134Z" fill="#d4faff"/>
|
||||
<path d="M204 116 228 109 5000-2470V2760L220 128Z" fill="#fcffff"/>
|
||||
<path d="m218 108 193-120-164 117 380-197m-411 222 222 151-192-145 376 206" stroke="#f4ffff" stroke-width="4"/>
|
||||
<path d="m236 103 154-85m-149 114 212 129m-177-150 340-159m-350 176 446 218" stroke="#79c9df" stroke-width="2"/>
|
||||
<path d="M184 122q12-20 36-19m-28 28q12 8 27 1" stroke="#f7ffff" stroke-width="5"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cutscene-panel cutscene-fallen beam-aftermath" data-panel="fallen">
|
||||
<div class="beam-reaction" data-panel="reaction">
|
||||
<div class="beam-shock-lines"></div>
|
||||
<div class="fighter beam-reaction-portrait" data-fighter="${hitIndex}" data-hit="true">
|
||||
<svg class="beam-reaction-art" viewBox="0 0 136 130" fill="none" aria-hidden="true" style="${fighterPalette(hitIndex, appearances?.[hitIndex])}">
|
||||
<defs><mask id="${reactionMaskId}" maskUnits="userSpaceOnUse" x="0" y="0" width="136" height="130">
|
||||
<rect width="136" height="130" fill="white"/>
|
||||
<path d="M0 0H28L20 12 30 19 22 25 28 32 19 42 28 46 23 56 32 61 24 72 32 80 22 92 29 99 16 109 25 119 17 130H0Z" fill="black"/>
|
||||
<path class="beam-reaction-erosion" d="M85 0 79 12 90 20 77 26 89 34 73 42 83 50 72 57 79 68 68 77 78 85 63 97 75 112 58 130H136V0Z" fill="black"/>
|
||||
<path d="m26 47 11-5-6 7 7 5-10-3Zm48 18-10 6 6 3-9 8 4-11Zm-43 37 14-7-7 8 3 6-10-3Z" fill="black"/>
|
||||
</mask></defs>
|
||||
<g class="beam-reaction-remnant" mask="url(#${reactionMaskId})">${fighterArt(hitIndex, true, appearances?.[hitIndex])}</g>
|
||||
<g class="beam-reaction-light" stroke="#c1f5fa" stroke-width=".8" stroke-linejoin="round">
|
||||
<path d="m84 1-3 11 11 8-12 6 11 8-15 8 10 8-11 7 7 11-11 9 10 8-15 12 12 15-17 18h8l17-19-11-14 13-12-9-10 9-8-8-10 10-8-9-8 14-7-11-9 11-6-11-9 4-10Z" fill="#fcffff"/>
|
||||
<path d="m28 30-6 12 8 4-4 11 9 4-6 11 6 7-7-3 3-13-9-5 3-11-8-4 8-13Zm44 31-8 10 6 3-10 11 6-12-6-2Z" fill="#fcffff"/>
|
||||
<path d="m88 30 8-5-3 6Zm-3 31 6-3-2 5Zm-8 33 7-4-3 6Z" fill="var(--skin)" stroke="none"/>
|
||||
<path d="m87 13 7-4-2 5Zm9 4 4-2-1 3Z" fill="var(--${victim?.hairStyle === 'bald' ? 'skin' : 'hair'})" stroke="none"/>
|
||||
<path d="m81 106 7-3-3 5Zm-12 17 9-4-4 5Z" fill="var(--suit)" stroke="none"/>
|
||||
<path d="m94 43 18-9m-24 40 22-11m-37 51 27-15m-77-32 12-7" fill="none"/>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="beam-dissolve" data-panel="dissolve">
|
||||
<div class="beam-dissolve-lines"></div>
|
||||
<svg class="beam-dissolve-art" viewBox="0 0 260 300" fill="none" aria-hidden="true" style="${fighterPalette(hitIndex, appearances?.[hitIndex])}">
|
||||
<defs><mask id="${maskId}" maskUnits="userSpaceOnUse" x="0" y="0" width="260" height="300">
|
||||
<rect width="260" height="300" fill="black"/>
|
||||
<path d="m90 27 21-8-8 10 12 4-16 9-13-3 9-5Zm23 18 19-7-6 10-13 4Zm-10 31 13-9-4 12 8 6-16 5-7-7Z" fill="white"/>
|
||||
<path d="m117 110 16-8-5 13-13 5Zm-29 23 12-6-4 9-10 3Zm31 32 18-9-7 11-12 4Zm-4 30 10-6-4 8Z" fill="#888"/>
|
||||
</mask></defs>
|
||||
<g class="fighter beam-remnant" data-fighter="${hitIndex}" data-hit="true" mask="url(#${maskId})">${fighterArt(hitIndex, false, appearances?.[hitIndex])}</g>
|
||||
<g class="beam-vanish-wake" stroke="#81b3bb" stroke-linecap="round">
|
||||
<path d="M59 63q30-19 68-19m-60 30 29-12m10-20 48-13M72 113q27-22 57-23m-49 31 26-13m20-22 49-13M82 166q23-17 47-19m-36 26 27-14m9-20 43-12M94 214l35-16m-19 27 32-19" stroke-width="1.4"/>
|
||||
<path d="m54 76 18-8m-9 49 17-7m-4 62 16-8m-5 63 24-13m24-165 28-11m-25 66 44-17m-45 77 28-12" stroke-width=".7"/>
|
||||
</g>
|
||||
<g class="beam-fragments" stroke="none">
|
||||
<path d="m118 35 7-4-3 5Zm16-10 5-2-2 3Zm-28 27 5-3-1 3Z" fill="var(--${victim?.hairStyle === 'bald' ? 'skin' : 'hair'})"/>
|
||||
<path d="m124 73 7-3-4 5Zm-14 16 5-2-2 3Z" fill="var(--skin)"/>
|
||||
<path d="m134 111 8-4-4 5Zm-17 20 5-3-2 4Zm17 28 7-3-3 4Zm-10 28 5-2-2 3Z" fill="var(--suit)"/>
|
||||
<path d="m128 225 5-3-2 4Zm13-15 4-2-1 2Z" fill="${equipped(victim, 'shoes') ? '#30383c' : 'var(--skin)'}"/>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
export function finisherArt(hitIndex: 0 | 1, attack: ActionId, appearances?: [FighterAppearance, FighterAppearance]) {
|
||||
if (attack === 'beam') return beamFinisherArt(hitIndex, appearances);
|
||||
const attacker = 1 - hitIndex;
|
||||
const speedLines = Array.from({ length: 44 }, (_, i) => {
|
||||
const angle = i * Math.PI / 22, inner = 85 + (i % 4) * 21;
|
||||
const x = (r: number) => (400 + Math.cos(angle) * r).toFixed(1);
|
||||
const y = (r: number) => (170 + Math.sin(angle) * r * .65).toFixed(1);
|
||||
return `<path d="M${x(inner)} ${y(inner)} ${x(740)} ${y(740)}" stroke-width="${i % 3 === 0 ? 5 : 1.5}"/>`;
|
||||
}).join('');
|
||||
return `<div class="cutscene-page" aria-hidden="true">
|
||||
<div class="cutscene-panel cutscene-attack" data-panel="attack">
|
||||
<div class="cutscene-ink-strokes"></div>
|
||||
<div class="fighter cutscene-closeup" data-fighter="${attacker}" data-action="${attack}"><div class="character-direction">${fighterArt(attacker, true, appearances?.[attacker])}</div></div>
|
||||
</div>
|
||||
<div class="cutscene-panel cutscene-impact" data-panel="impact">
|
||||
<svg class="cutscene-speedlines" viewBox="0 0 800 340" preserveAspectRatio="none" fill="none"><g stroke="#292d2b">${speedLines}</g></svg>
|
||||
${[0, 1].map(index => `<div class="fighter cutscene-combatant" data-fighter="${index}" data-hit="${index === hitIndex}"${index === attacker ? ` data-action="${attack}"` : ''}><div class="character-direction">${fighterArt(index, false, appearances?.[index])}</div></div>`).join('')}
|
||||
<div class="cutscene-shot"></div>
|
||||
<svg class="cutscene-contact" viewBox="0 0 240 240"><path d="m122 6 11 62 46-50-22 64 73-19-51 45 55 19-63 10 40 61-62-29-18 66-17-62-67 49 33-66-71-3 58-38L12 57l69 28-3-68 31 59Z" fill="#f6eed2" stroke="#28292a" stroke-width="3"/><path d="m119 55 10 42 38-24-22 40 42 11-43 14 15 33-35-24-20 42-2-47-39 8 30-26-29-26 43 11Z" fill="#a84a34"/></svg>
|
||||
<strong class="cutscene-sfx">쾅!</strong>
|
||||
</div>
|
||||
<div class="cutscene-panel cutscene-fallen" data-panel="fallen">${fallenFighterArt(hitIndex, appearances?.[hitIndex])}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
export function ashPileArt() {
|
||||
return `<svg class="ash-pile-art" viewBox="0 0 260 300" fill="none" aria-hidden="true">
|
||||
<path d="m39 279 28-7 39-3 52 1 42 5 20 8-33 5-65 2-55-4Z" fill="#46504b" opacity=".18"/>
|
||||
<g stroke="#414a47" stroke-width="2" stroke-linejoin="round" stroke-linecap="round">
|
||||
<path d="m49 278 18-7 12-1 9-8 15 1 11-9 14 3 12-6 13 7 15-1 10 8 15 2 8 9 13 5-23 3-27-1-18 4-30-2-23 1-19-5-14 1Z" fill="#898e87"/>
|
||||
<path d="m64 277 25-4 18 3 22-6 18 4 20-3 17 7 18 1-11 5-27-1-18 4-30-2-23 1-19-5-14 1Z" fill="#606a65" stroke="none"/>
|
||||
<path d="m88 269 13-3 10 3m10-8 9 4 11-3m12 4 12 2m-58 10 13-3m20 4 13-2m18-1 8 3" stroke-width="1.3"/>
|
||||
<path d="m41 282 5-3 5 3-5 2Zm175-8 5 1-1 3-6-1Zm-141 16 6-2 5 2-6 1Zm103-1 8-2 4 2-7 1Z" fill="#707973" stroke-width="1"/>
|
||||
</g>
|
||||
<g class="ash-drift" fill="#66716b">
|
||||
<path d="m95 241 4-3 3 2-4 3Zm37-16 3-4 2 2-2 4Zm29 18 4-2 2 2-4 2Z"/>
|
||||
</g>
|
||||
<g class="ash-drift ash-drift-late" fill="#858e86">
|
||||
<path d="m111 234 3-2 2 2-3 2Zm38-20 2-3 2 2-2 2Zm27 22 3-2 2 1-3 3Z"/>
|
||||
</g>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
export function actionIcon(action: ActionId) {
|
||||
const shapes: Record<ActionId, string> = {
|
||||
charge: '<path d="M29 3 9 28h13l-3 17 21-26H27Z"/>',
|
||||
blast: '<path fill-rule="evenodd" d="M27 8h9l10 16-10 16h-9L15 30H3l9-6-9-6h12Zm2 10-5 6 5 6h4l4-6-4-6Z"/><path d="M3 8h14v6H3Zm0 26h14v6H3Z"/>',
|
||||
guard: '<path fill-rule="evenodd" d="m24 3 19 7v14l-6 12-13 9-13-9-6-12V10Zm0 9-10 4v8l5 8 5 4 5-4 5-8v-8Z"/>',
|
||||
beam: '<path fill-rule="evenodd" d="m4 18 9-6 10 5L45 4v40L23 31l-10 5-9-6Zm8 3v6h9l24 8V13l-24 8Z"/><path d="M17 22h28v4H17Z"/>',
|
||||
dodge: '<path d="m32 3 8 3-3 8-8-3ZM23 14l10 3 4 8h8v6H32l-4-6-3 8 7 4-2 9h-7l1-6-8-5 4-13h-6l-4 7H3l7-14Zm-10 23h7l-7 8H4ZM3 5h18v5H3Z"/>',
|
||||
};
|
||||
return `<svg viewBox="0 0 48 48" fill="currentColor" aria-hidden="true">${shapes[action]}</svg>`;
|
||||
}
|
||||
|
||||
export const checkIcon = '<svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="m5 12 5 5L20 7" stroke="currentColor" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/></svg>';
|
||||
@@ -0,0 +1,105 @@
|
||||
import { CUTSCENE_TIMING } from '../../constants/cutscene.js';
|
||||
import { ACTIONS } from '../../constants/gameplay.js';
|
||||
import type { LocalMatchState } from '../shared/p2p-types.js';
|
||||
import { appearanceKey, type FighterAppearance } from '../shared/cosmetics.js';
|
||||
import { finisherArt } from './fighter-art.js';
|
||||
|
||||
// Survives view replacement on reconnect, without persisting anything to game state.
|
||||
let presentation: { matchId: string; startedAt: number; dismissed: boolean } | undefined;
|
||||
|
||||
export function createFinishCutscene(onDismiss: () => void, appearances?: [FighterAppearance, FighterAppearance]) {
|
||||
let dialog: HTMLDialogElement | undefined, timer: ReturnType<typeof setTimeout> | undefined;
|
||||
let returnFocus: HTMLElement | undefined, previousBodyState: string | undefined;
|
||||
let activeMatch = '', resultAt = 0, artSignature = '';
|
||||
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
|
||||
|
||||
function close(restoreFocus: boolean) {
|
||||
clearTimeout(timer);
|
||||
if (!dialog) return;
|
||||
const node = dialog;
|
||||
dialog = undefined; activeMatch = '';
|
||||
if (previousBodyState === undefined) delete document.body.dataset.cutscene;
|
||||
else document.body.dataset.cutscene = previousBodyState;
|
||||
node.close(); node.remove();
|
||||
if (restoreFocus && returnFocus?.isConnected) returnFocus.focus({ preventScroll: true });
|
||||
returnFocus = undefined;
|
||||
}
|
||||
|
||||
function dismiss() {
|
||||
if (!dialog || dialog.dataset.stage !== 'complete') return;
|
||||
if (presentation?.matchId === activeMatch) presentation.dismissed = true;
|
||||
close(false);
|
||||
onDismiss();
|
||||
}
|
||||
|
||||
function advance() {
|
||||
if (!dialog || presentation?.matchId !== activeMatch) return;
|
||||
clearTimeout(timer);
|
||||
const now = performance.now(), elapsed = now - presentation.startedAt;
|
||||
const complete = (reducedMotion.matches || elapsed >= CUTSCENE_TIMING.complete) && now >= resultAt;
|
||||
const reactionEnds = dialog.dataset.attack === 'beam' ? CUTSCENE_TIMING.beamDissolve : CUTSCENE_TIMING.fallen;
|
||||
const stage = complete ? 'complete' : reducedMotion.matches || elapsed >= reactionEnds ? 'fallen'
|
||||
: elapsed >= CUTSCENE_TIMING.fallen ? 'reaction'
|
||||
: elapsed >= CUTSCENE_TIMING.impact ? 'impact' : 'attack';
|
||||
dialog.dataset.stage = stage;
|
||||
const button = dialog.querySelector<HTMLButtonElement>('.cutscene-result')!;
|
||||
button.hidden = !complete;
|
||||
if (complete) {
|
||||
if (document.activeElement === dialog) button.focus({ preventScroll: true });
|
||||
} else {
|
||||
const next = reducedMotion.matches ? resultAt : Math.max(resultAt, presentation.startedAt + CUTSCENE_TIMING.complete);
|
||||
const boundary = !reducedMotion.matches && elapsed < CUTSCENE_TIMING.impact ? presentation.startedAt + CUTSCENE_TIMING.impact
|
||||
: !reducedMotion.matches && elapsed < CUTSCENE_TIMING.fallen ? presentation.startedAt + CUTSCENE_TIMING.fallen
|
||||
: !reducedMotion.matches && elapsed < reactionEnds ? presentation.startedAt + reactionEnds : next;
|
||||
timer = setTimeout(advance, Math.max(0, boundary - now));
|
||||
}
|
||||
}
|
||||
|
||||
reducedMotion.addEventListener('change', advance);
|
||||
return {
|
||||
update(state: LocalMatchState) {
|
||||
const reveal = state.reveal;
|
||||
if (activeMatch && activeMatch !== state.matchId) close(true);
|
||||
if (state.aborted || state.finished?.reason !== 'hit' || !reveal || reveal.matchId !== state.matchId || reveal.hitIndex === null) {
|
||||
close(true); return;
|
||||
}
|
||||
if (presentation?.matchId === state.matchId && presentation.dismissed) return;
|
||||
const chosenAppearances = state.appearances ?? appearances;
|
||||
const signature = [0, 1].map(i => appearanceKey(chosenAppearances?.[i])).join('|');
|
||||
const hit = reveal.hitIndex, attacker = 1 - hit;
|
||||
if (dialog) {
|
||||
if (artSignature !== signature) {
|
||||
dialog.querySelector('.cutscene-page')!.outerHTML = finisherArt(hit, reveal.actions[attacker], chosenAppearances);
|
||||
artSignature = signature;
|
||||
}
|
||||
return;
|
||||
}
|
||||
const now = performance.now();
|
||||
if (presentation?.matchId !== state.matchId) {
|
||||
presentation = { matchId: state.matchId, startedAt: now - Math.max(0, state.clockNow - reveal.revealedAt), dismissed: false };
|
||||
}
|
||||
resultAt = now + Math.max(0, reveal.revealEndsAt - state.clockNow);
|
||||
activeMatch = state.matchId;
|
||||
returnFocus = document.activeElement instanceof HTMLElement ? document.activeElement : undefined;
|
||||
previousBodyState = document.body.dataset.cutscene;
|
||||
const name = (index: number) => index === state.selfIndex ? '나' : state.mode === 'bot' ? '봇' : '상대';
|
||||
dialog = document.createElement('dialog');
|
||||
dialog.className = 'finish-cutscene'; dialog.tabIndex = -1;
|
||||
dialog.dataset.hitIndex = String(hit);
|
||||
dialog.dataset.attack = reveal.actions[attacker];
|
||||
dialog.dataset.stage = 'attack';
|
||||
dialog.setAttribute('aria-label', `${name(attacker)}의 ${ACTIONS[reveal.actions[attacker]].name} 명중. ${hit === state.selfIndex ? '패배' : '승리'}.`);
|
||||
dialog.innerHTML = `${finisherArt(hit, reveal.actions[attacker], chosenAppearances)}<button class="cutscene-result" hidden>결과 보기</button>`;
|
||||
artSignature = signature;
|
||||
dialog.querySelector<HTMLButtonElement>('.cutscene-result')!.onclick = dismiss;
|
||||
dialog.addEventListener('cancel', event => { event.preventDefault(); dismiss(); });
|
||||
document.body.append(dialog);
|
||||
document.body.dataset.cutscene = 'active';
|
||||
dialog.showModal();
|
||||
dialog.focus({ preventScroll: true });
|
||||
advance();
|
||||
},
|
||||
reset() { close(true); },
|
||||
destroy() { close(true); reducedMotion.removeEventListener('change', advance); },
|
||||
};
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,93 @@
|
||||
Copyright 2015 The Black Han Sans Project Authors (https://github.com/zesstype/Black-Han-Sans)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://openfontlicense.org
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -0,0 +1,202 @@
|
||||
import { MATCH_COUNTDOWN_MS, RULES_VERSION } 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';
|
||||
import { createTurnExchange, digestValue, type TurnExchange } from '../shared/peer-protocol.js';
|
||||
import type { MatchCheckpoint, PeerMessage, PublicCheckpoint } from '../shared/p2p-types.js';
|
||||
import type { ActionId, Pair } from '../shared/types.js';
|
||||
import type { GameClock } from './match-controller.js';
|
||||
import type { PeerConnection } from './peer.js';
|
||||
|
||||
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;
|
||||
saved?: { confirmed?: MatchCheckpoint; epoch: number };
|
||||
}
|
||||
export function createFriendMatch(config: FriendConfig) {
|
||||
const { peer, engine, clock } = config;
|
||||
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 presentAt: number | undefined, privateChoice: { action: ActionId | null; nonce: string } | undefined;
|
||||
let resumedPeer = false, resumedAck = false, lastPeerAt = clock.now(), queue = Promise.resolve();
|
||||
let leaveResolve: (() => void) | undefined;
|
||||
let leaving = false;
|
||||
const timers = new Map<string, any>();
|
||||
const clockReplies = new Map<number, { resolve(value: number): void; reject(error: Error): void }>();
|
||||
const now = () => clock.now() + offset;
|
||||
function clear(name: string) { clock.clearTimeout(timers.get(name)); timers.delete(name); }
|
||||
function later(name: string, fn: () => void, ms: number) { clear(name); timers.set(name, clock.setTimeout(() => { timers.delete(name); if (!stopped) fn(); }, Math.max(0, ms))); }
|
||||
function clearAll() { for (const name of [...timers.keys()]) clear(name); }
|
||||
function run(fn: () => unknown | Promise<unknown>) { void Promise.resolve().then(fn).catch(() => { if (!stopped) abort('protocol_error'); }); }
|
||||
function send(type: PeerMessage['type'], fields: Record<string, unknown> = {}) {
|
||||
peer.send({ protocolVersion: PROTOCOL_VERSION, rulesVersion: RULES_VERSION, matchId: config.matchId,
|
||||
connectionEpoch: peer.epoch, turnId: engine.view().turnId, type, ...fields } as PeerMessage);
|
||||
}
|
||||
function abort(reason: 'connection_failed' | 'protocol_error') {
|
||||
if (stopped) return;
|
||||
clearAll(); recovering = false;
|
||||
// An unconfirmed calculation is not a final result and may be discarded.
|
||||
if (!presented && confirmed.state.phase !== 'finished') engine.restore(confirmed);
|
||||
engine.abort(reason); config.changed(); config.finished();
|
||||
stopped = true; peer.close(); leaveResolve?.();
|
||||
}
|
||||
function armProtocol() { later('protocol', () => abort('protocol_error'), 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],
|
||||
finished: state.phase === 'finished', winnerIndex: state.finished?.winnerIndex ?? null, ...(state.finished ? { reason: state.finished.reason } : {}) };
|
||||
}
|
||||
function sameCheckpoint(a: PublicCheckpoint, b: PublicCheckpoint) {
|
||||
return a.turnId === b.turnId && a.energy?.[0] === b.energy[0] && a.energy?.[1] === b.energy[1] && a.finished === b.finished && a.winnerIndex === b.winnerIndex && a.reason === b.reason;
|
||||
}
|
||||
function heartbeat() {
|
||||
if (stopped || recovering) return;
|
||||
if (clock.now() - lastPeerAt >= PEER_SILENCE_MS) { void recover(); return; }
|
||||
try { send('ping', { sentAt: clock.now() }); } catch { void recover(); return; }
|
||||
later('heartbeat', heartbeat, PEER_PING_MS);
|
||||
}
|
||||
async function synchronize() {
|
||||
if (config.slot === 0) return;
|
||||
let bestRtt = Infinity, bestOffset = offset;
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const sentAt = clock.now();
|
||||
const response = new Promise<number>((resolve, reject) => clockReplies.set(sentAt, { resolve, reject }));
|
||||
later('clock', () => { clockReplies.get(sentAt)?.reject(new Error('CLOCK_TIMEOUT')); clockReplies.delete(sentAt); }, PROTOCOL_TIMEOUT_MS);
|
||||
send('clock:ping', { sentAt }); const remoteAt = await response;
|
||||
clear('clock'); const rtt = clock.now() - sentAt;
|
||||
if (rtt < bestRtt) { bestRtt = rtt; bestOffset = remoteAt - (sentAt + clock.now()) / 2; }
|
||||
}
|
||||
offset = bestOffset; config.setOffset(offset);
|
||||
}
|
||||
function present(at: number) {
|
||||
if (presented) return;
|
||||
presented = true; presentAt = undefined; clear('protocol'); engine.present(at); confirmed = engine.checkpoint();
|
||||
config.changed();
|
||||
if (engine.view().phase === 'finished') config.finished();
|
||||
else if (config.slot === 0) later('next', () => startTurn(engine.view().reveal!.revealEndsAt), engine.view().reveal!.revealEndsAt - now());
|
||||
}
|
||||
function agree() {
|
||||
if (!ownDigest || !remoteDigest) return;
|
||||
if (ownDigest !== remoteDigest) { abort('protocol_error'); return; }
|
||||
if (!sentAck) { sentAck = true; send('turn:ack', { digest: ownDigest }); }
|
||||
if (remoteAck && remoteAck !== ownDigest) { abort('protocol_error'); return; }
|
||||
if (remoteAck !== ownDigest || presented) return;
|
||||
// Agreement survives loss of the presentation packet and a page reload.
|
||||
confirmed = engine.checkpoint(); config.changed();
|
||||
if (confirmed.state.phase === 'finished') config.finished();
|
||||
if (config.slot === 0) { const at = now(); send('turn:present', { revealedAt: at }); present(at); }
|
||||
else if (presentAt !== undefined) present(presentAt);
|
||||
}
|
||||
async function resolved(actions: Pair<ActionId | null>) {
|
||||
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]);
|
||||
if (stopped || recovering) return;
|
||||
send('turn:resolved', { digest: ownDigest }); agree();
|
||||
}
|
||||
function selection() {
|
||||
privateChoice = undefined; ownDigest = remoteDigest = remoteAck = ''; sentAck = false; presented = false; presentAt = undefined;
|
||||
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: () => armProtocol() });
|
||||
config.changed();
|
||||
later('deadline', () => run(() => exchange!.commit(engine.view().ownAction)), state.deadline - now());
|
||||
}
|
||||
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;
|
||||
}
|
||||
if (!begun) { begun = true; engine.start(startsAt - MATCH_COUNTDOWN_MS); confirmed = engine.checkpoint(); config.changed(); }
|
||||
later('start', () => { if (engine.beginTurn(startsAt)) selection(); }, startsAt - now());
|
||||
}
|
||||
function startTurn(startsAt: number, resume = false) {
|
||||
const turnId = resume ? publicCheckpoint().turnId : engine.view().turnId + 1;
|
||||
send('turn:start', { startsAt, resume, turnId }); applyStart(startsAt, resume);
|
||||
}
|
||||
function maybeStart() {
|
||||
if (localReady && peerReady && config.slot === 0 && !begun && !recovering) startTurn(now() + MATCH_COUNTDOWN_MS);
|
||||
}
|
||||
function maybeResume() {
|
||||
if (!recovering || !resumedPeer || !resumedAck) return;
|
||||
if (confirmed.state.phase === 'finished') { engine.restore(confirmed); recovering = false; clear('recovery'); config.changed(); config.finished(); return; }
|
||||
if (config.slot === 0) startTurn(now() + RESUME_START_DELAY_MS, true);
|
||||
}
|
||||
async function recover(disconnectedAt = clock.now()) {
|
||||
if (stopped || recovering) return;
|
||||
if (confirmed.state.phase === 'finished') {
|
||||
clearAll(); if (engine.view().phase !== 'finished') engine.restore(confirmed);
|
||||
engine.present(now()); engine.presence(false); config.changed(); config.finished(); return;
|
||||
}
|
||||
recovering = true; resumedPeer = resumedAck = false; clearAll(); exchange = undefined;
|
||||
engine.presence(false); config.changed();
|
||||
later('recovery', () => abort('connection_failed'), RECONNECT_GRACE_MS - (clock.now() - disconnectedAt));
|
||||
try {
|
||||
await peer.restart(peer.epoch + 1); if (stopped) return;
|
||||
lastPeerAt = clock.now(); await synchronize(); if (stopped) return;
|
||||
send('resume', { checkpoint: publicCheckpoint() });
|
||||
} catch { abort('connection_failed'); }
|
||||
}
|
||||
async function receive(message: PeerMessage) {
|
||||
if (stopped || !message || message.matchId !== config.matchId || message.protocolVersion !== PROTOCOL_VERSION || message.rulesVersion !== RULES_VERSION || message.connectionEpoch !== peer.epoch) return;
|
||||
lastPeerAt = clock.now();
|
||||
if (message.type === 'ping' || message.type === 'clock:ping') { send(message.type === 'ping' ? 'pong' : 'clock:pong', { sentAt: message.sentAt, remoteAt: now() }); return; }
|
||||
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 === '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; }
|
||||
if (message.type === 'resume') { resumedPeer = true; send('resume:ack', { checkpoint: publicCheckpoint() }); }
|
||||
else resumedAck = true;
|
||||
maybeResume(); return;
|
||||
}
|
||||
if (message.type === 'turn:start') {
|
||||
if (config.slot !== 1) throw new Error('INVALID_ROLE');
|
||||
if (message.resume) { if (recovering && resumedPeer && resumedAck) applyStart(message.startsAt, true); return; }
|
||||
if (recovering || message.turnId !== engine.view().turnId + 1) return;
|
||||
applyStart(message.startsAt); return;
|
||||
}
|
||||
if (message.type === 'match:leave') {
|
||||
send('match:leave-ack'); clearAll(); engine.finish(leaving ? null : config.slot, 'forfeit'); confirmed = engine.checkpoint(); presented = true; config.changed(); config.finished(); return;
|
||||
}
|
||||
if (message.type === 'match:leave-ack') { leaveResolve?.(); return; }
|
||||
if (message.type === 'match:rematch') { engine.requestRematch(config.slot === 0 ? 1 : 0); config.changed(); return; }
|
||||
if (recovering || message.turnId !== engine.view().turnId) 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(); }
|
||||
else if (message.type === 'turn:present') { if (config.slot !== 1 || !Number.isFinite(message.revealedAt)) throw new Error('INVALID_PRESENT'); presentAt = message.revealedAt; agree(); }
|
||||
}
|
||||
const offMessage = peer.onMessage(message => { queue = queue.then(() => receive(message)).catch(() => abort('protocol_error')); });
|
||||
const offState = peer.onState(state => {
|
||||
if (state === 'disconnected' || state === 'failed') { const disconnectedAt = clock.now(); void queue.then(() => recover(disconnectedAt)); }
|
||||
});
|
||||
return {
|
||||
async start() {
|
||||
if (config.saved?.confirmed) {
|
||||
begun = true; engine.restore(confirmed); await recover(); return;
|
||||
}
|
||||
try { await peer.connect(); await synchronize(); if (stopped) return; localReady = true; send('ready', { slot: config.slot }); maybeStart(); lastPeerAt = clock.now(); heartbeat(); }
|
||||
catch { abort('connection_failed'); }
|
||||
},
|
||||
get recovering() { return recovering; },
|
||||
requestRematch() { engine.requestRematch(config.slot); if (peer.connected) send('match:rematch'); config.changed(); },
|
||||
snapshot: () => ({ confirmed: structuredClone(confirmed), epoch: peer.epoch, privateChoice }),
|
||||
async leave() {
|
||||
if (engine.view().phase === 'finished') return;
|
||||
leaving = true;
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
leaveResolve = resolve; later('leave', () => reject(new Error('LEAVE_TIMEOUT')), PROTOCOL_TIMEOUT_MS); send('match:leave');
|
||||
});
|
||||
clearAll(); engine.finish(config.slot === 0 ? 1 : 0, 'forfeit'); confirmed = engine.checkpoint(); presented = true; config.changed(); config.finished();
|
||||
} catch { abort('connection_failed'); }
|
||||
finally { leaveResolve = undefined; }
|
||||
},
|
||||
destroy() { stopped = true; clearAll(); offMessage(); offState(); for (const pending of clockReplies.values()) pending.reject(new Error('CLOSED')); clockReplies.clear(); peer.close(); },
|
||||
};
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,177 @@
|
||||
/* Home and room entry only; all selectors are scoped to the player app. */
|
||||
body.player-app:is([data-screen=home], [data-screen=join]) .app {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
padding: 0;
|
||||
}
|
||||
body.player-app:is([data-screen=home], [data-screen=join]) main { min-height: 100svh; padding: 0; }
|
||||
body.player-app .home {
|
||||
flex: 1;
|
||||
grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr);
|
||||
gap: 0;
|
||||
max-width: none;
|
||||
margin: 0;
|
||||
align-items: stretch;
|
||||
}
|
||||
body.player-app .home-stage {
|
||||
min-height: 100svh;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: linear-gradient(125deg, #ffbd43, #ff8b26 75%);
|
||||
clip-path: polygon(0 0, 100% 0, 83% 100%, 0 100%);
|
||||
}
|
||||
body.player-app .home-stage::before {
|
||||
inset: 16% auto auto -28%;
|
||||
width: 660px;
|
||||
height: 660px;
|
||||
border: 2px solid #fff4ce80;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, #fff3c14d 1px, transparent 1.5px) 0 0 / 12px 12px;
|
||||
}
|
||||
body.player-app .home-stage::after {
|
||||
inset: 0;
|
||||
width: auto;
|
||||
height: auto;
|
||||
border: 0;
|
||||
transform: none;
|
||||
background: repeating-linear-gradient(118deg, transparent 0 120px, #fff4ce35 121px 123px, transparent 124px 220px);
|
||||
}
|
||||
body.player-app .home h1 {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
top: max(40px, env(safe-area-inset-top));
|
||||
left: max(36px, env(safe-area-inset-left));
|
||||
margin: 0;
|
||||
color: #202122;
|
||||
font-size: clamp(54px, 6.2vw, 104px);
|
||||
line-height: 1.12;
|
||||
letter-spacing: -.055em;
|
||||
text-align: left;
|
||||
transform: rotate(-5deg);
|
||||
text-shadow: 3px 4px 0 #ffe6a3;
|
||||
}
|
||||
body.player-app .home h1 span { display: block; }
|
||||
body.player-app .home h1 span:last-child { font-size: .82em; }
|
||||
body.player-app #home-fighters {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
body.player-app .home-fighter { position: absolute; max-width: none; filter: drop-shadow(8px 8px 0 #70391b25); }
|
||||
body.player-app .home-fighter:first-child { width: 89%; left: -15%; top: max(230px, 30%); z-index: 2; transform: rotate(-9deg); }
|
||||
body.player-app .home-fighter:last-child { width: 73%; right: -8%; top: max(250px, 33%); z-index: 1; transform: scaleX(-1) rotate(-8deg); }
|
||||
body.player-app .home-fighter .character { animation: none; }
|
||||
body.player-app .home-fighter .ground-shadow, body.player-app .home-versus { display: none; }
|
||||
body.player-app .home-fighter-teleport { width: 54%; left: 34%; top: max(80px, 10%); z-index: 0; transform: rotate(4deg); }
|
||||
body.player-app .home-entering .home-fighter-left { animation: home-slide-left .65s cubic-bezier(.16, 1, .3, 1) both; }
|
||||
body.player-app .home-entering .home-fighter-right { animation: home-slide-right .65s .25s cubic-bezier(.16, 1, .3, 1) both; }
|
||||
body.player-app .home-entering .home-fighter-teleport { animation: home-teleport-in .45s .9s steps(1, end) both; }
|
||||
body.player-app .home-entering .home-fighter-teleport::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 5% 22% 12%;
|
||||
pointer-events: none;
|
||||
background: repeating-linear-gradient(90deg, transparent 0 13%, #fff4ce 14% 16%, transparent 17% 25%);
|
||||
animation: home-teleport-streaks .45s .9s ease-out both;
|
||||
}
|
||||
@keyframes home-slide-left {
|
||||
from { translate: -120% 0; opacity: 0; }
|
||||
to { translate: 0 0; opacity: 1; }
|
||||
}
|
||||
@keyframes home-slide-right {
|
||||
from { translate: 120% 0; opacity: 0; }
|
||||
to { translate: 0 0; opacity: 1; }
|
||||
}
|
||||
@keyframes home-teleport-in {
|
||||
0% { opacity: 0; }
|
||||
20% { opacity: .65; translate: 0 -12px; }
|
||||
35% { opacity: 0; }
|
||||
50% { opacity: 1; translate: 0 0; }
|
||||
65% { opacity: .3; }
|
||||
80%, 100% { opacity: 1; translate: 0 0; }
|
||||
}
|
||||
@keyframes home-teleport-streaks {
|
||||
0%, 100% { opacity: 0; scale: 1 .8; }
|
||||
20% { opacity: .9; scale: 1 1.2; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
body.player-app .home-entering .home-fighter { animation: none; }
|
||||
body.player-app .home-entering .home-fighter-teleport::after { display: none; }
|
||||
}
|
||||
body.player-app .home-menu {
|
||||
position: relative;
|
||||
z-index: 4;
|
||||
align-self: center;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
margin-left: -24px;
|
||||
padding: 64px max(32px, 4vw) 120px 0;
|
||||
}
|
||||
body.player-app .home-actions, body.player-app .home-actions .split { display: grid; gap: 14px; }
|
||||
body.player-app .home-actions button {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
min-height: 80px;
|
||||
padding: 20px 28px;
|
||||
font-size: clamp(22px, 2.3vw, 32px);
|
||||
letter-spacing: -.035em;
|
||||
text-align: left;
|
||||
}
|
||||
body.player-app .home-actions > .primary { min-height: 108px; padding: 24px 28px; font-size: clamp(30px, 3.1vw, 44px); }
|
||||
body.player-app .home-actions svg { flex: 0 0 28px; width: 28px; height: 28px; }
|
||||
body.player-app .home-actions > .primary svg { flex-basis: 36px; width: 36px; height: 36px; }
|
||||
body.player-app .home .stats { margin-top: 32px; padding-top: 20px; border-top: 2px solid #242526; }
|
||||
body.player-app .home .stat-mode { padding: 0 20px; }
|
||||
body.player-app .home .stat-mode:first-child { padding-left: 0; }
|
||||
body.player-app .home .stat-mode + .stat-mode { border-left: 1px solid #d6d6d6; }
|
||||
body.player-app .home .stat-mode > span { font-size: 13px; color: #242526; }
|
||||
body.player-app .home .stat-mode > div { gap: 24px; color: #606060; }
|
||||
body.player-app .home .stat-mode strong { color: #242526; font-size: 30px; }
|
||||
body.player-app .home .join-form { margin-top: 24px; padding: 20px; background: #fff; border: 2px solid #242526; box-shadow: 5px 5px 0 #2425261c; }
|
||||
body.player-app .home .join-form > .quiet { margin: 14px auto 0; }
|
||||
body.player-app:is([data-screen=home], [data-screen=join]) #notice:not(:empty) { position: absolute; bottom: 90px; right: 4vw; width: 36%; margin: 0; }
|
||||
body.player-app:is([data-screen=home], [data-screen=join]) #reconnect { position: absolute; bottom: 150px; right: 4vw; }
|
||||
@media (max-width: 1050px) and (min-width: 761px) {
|
||||
body.player-app .home-fighter:first-child { width: 106%; left: -23%; }
|
||||
body.player-app .home-fighter:last-child { width: 88%; right: -16%; }
|
||||
body.player-app .home h1 { left: 24px; }
|
||||
body.player-app .home-menu { margin-left: -12px; padding-right: 24px; }
|
||||
body.player-app .home-actions button { padding-inline: 20px; }
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
body.player-app:is([data-screen=home], [data-screen=join]) main { min-height: 0; }
|
||||
body.player-app .home { grid-template-columns: minmax(0, 1fr); }
|
||||
body.player-app .home-stage { height: clamp(300px, 48vw, 390px); min-height: 0; clip-path: polygon(0 0, 100% 0, 100% 88%, 0 100%); }
|
||||
body.player-app .home-stage::before { width: 420px; height: 420px; }
|
||||
body.player-app .home h1 { top: max(22px, env(safe-area-inset-top)); left: max(22px, env(safe-area-inset-left)); font-size: clamp(44px, 8vw, 60px); }
|
||||
body.player-app .home-fighter:first-child { width: 65%; left: 8%; top: 110px; }
|
||||
body.player-app .home-fighter:last-child { width: 54%; right: -4%; top: 115px; }
|
||||
body.player-app .home-fighter-teleport { width: 40%; left: 43%; top: -5px; }
|
||||
body.player-app .home-menu { width: min(100%, 520px); margin: -4px auto 0; padding: 0 max(20px, env(safe-area-inset-right)) 20px max(20px, env(safe-area-inset-left)); }
|
||||
body.player-app .home-actions, body.player-app .home-actions .split { gap: 12px; }
|
||||
body.player-app .home-actions button { min-height: 60px; padding: 13px 20px; font-size: 23px; }
|
||||
body.player-app .home-actions > .primary { min-height: 78px; padding: 16px 20px; font-size: 31px; }
|
||||
body.player-app .home-actions svg { width: 23px; height: 23px; flex-basis: 23px; }
|
||||
body.player-app .home-actions > .primary svg { width: 29px; height: 29px; flex-basis: 29px; }
|
||||
body.player-app .home .stats { margin-top: 24px; padding-top: 16px; }
|
||||
body.player-app .home .stat-mode { padding-inline: 16px; }
|
||||
body.player-app .home .stat-mode strong { font-size: 26px; }
|
||||
body.player-app .home .stat-mode > div { gap: 20px; }
|
||||
body.player-app .home .join-form { padding: 14px; }
|
||||
body.player-app:is([data-screen=home], [data-screen=join]) #notice:not(:empty),
|
||||
body.player-app:is([data-screen=home], [data-screen=join]) #reconnect { position: static; width: auto; margin: 12px 20px; }
|
||||
}
|
||||
@media (max-width: 380px) {
|
||||
body.player-app .home-stage { height: 270px; }
|
||||
body.player-app .home-actions button { min-height: 54px; padding-block: 10px; font-size: 21px; }
|
||||
body.player-app .home-actions > .primary { min-height: 68px; font-size: 28px; }
|
||||
body.player-app .home .stat-mode > div { gap: 16px; }
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"><meta name="theme-color" content="#ffffff"><title>쎄쎄쎄:기공대전</title></head>
|
||||
<body class="player-app" data-screen="home">
|
||||
<div class="app">
|
||||
<main id="main">
|
||||
<section id="home" class="home">
|
||||
<div class="home-stage"><h1 aria-label="쎄쎄쎄:기공대전"><span>쎄쎄쎄</span><span>:기공대전</span></h1><div id="home-fighters" aria-hidden="true"></div></div>
|
||||
<div class="home-menu">
|
||||
<div class="home-actions"><button id="bot-start" class="primary" disabled><span>봇과 대전</span><svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M4 12h15m-6-6 6 6-6 6" stroke="currentColor" stroke-width="3" stroke-linecap="square" stroke-linejoin="miter"/></svg></button><div class="split"><button id="room-create" disabled><span>방 만들기</span><svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M12 4v16M4 12h16" stroke="currentColor" stroke-width="2"/></svg></button><button id="join-open" disabled><span>방 참가</span><svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M13 4h7v16h-7M3 12h12m-5-5 5 5-5 5" stroke="currentColor" stroke-width="2"/></svg></button></div></div>
|
||||
<form id="join-form" class="join-form" hidden><label for="room-code">방 코드</label><div><input id="room-code" autocomplete="off" autocapitalize="characters" maxlength="6" pattern="[A-Za-z0-9]{6}" required><button type="submit" class="primary">참가</button></div><button type="button" id="join-close" class="quiet">취소</button></form>
|
||||
<div id="stats" class="stats"></div>
|
||||
</div>
|
||||
</section>
|
||||
<section id="waiting" class="waiting" hidden><div id="waiting-fighter" aria-hidden="true"></div><h1>상대 대기 중<span class="waiting-dots" aria-hidden="true"><i></i><i></i><i></i></span></h1><p class="room-code" id="waiting-code"></p><div class="split"><button id="copy-code">코드 복사</button><button id="copy-link">초대 링크 복사</button></div><p id="room-expiry" class="subtle"></p><button id="waiting-leave" class="quiet">나가기</button></section>
|
||||
<section id="match" hidden></section>
|
||||
<p id="notice" class="notice" role="status" aria-live="polite"></p>
|
||||
<button id="reconnect" class="primary" hidden>다시 시도</button>
|
||||
</main>
|
||||
<footer class="app-footer"><span id="connection" role="status">연결 중</span><nav aria-label="안내"><button type="button" class="quiet" id="rules-open" aria-haspopup="dialog" aria-controls="rules-dialog">rule</button><span aria-hidden="true">|</span><button type="button" class="quiet" id="privacy-open" aria-haspopup="dialog" aria-controls="privacy-dialog">privacy</button><span aria-hidden="true">|</span><button type="button" class="quiet" id="contact-open" aria-haspopup="dialog" aria-controls="contact-dialog">contact</button></nav></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>선택 시간 4초. 선택은 바꿀 수 없고 마감 때 함께 공개됩니다.</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>
|
||||
</body></html>
|
||||
@@ -0,0 +1,65 @@
|
||||
.lobby-tools { display: flex; gap: 10px; margin-top: 16px; }
|
||||
.lobby-tools button { flex: 1; min-width: 0; min-height: 48px; padding: 10px; font-size: 17px; }
|
||||
#home .home-actions > button:not(.primary) { min-height: 64px; font-size: 26px; }
|
||||
#home .home-actions .split { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
#home .home-actions .split button { min-height: 62px; font-size: 22px; padding-inline: 16px; }
|
||||
#home .home-actions .split svg { flex-basis: 22px; width: 22px; height: 22px; }
|
||||
#home .home-actions .split button span { white-space: nowrap; }
|
||||
.home .home-actions { gap: 12px; }
|
||||
.queue-fighter { width: min(300px, 75vw); margin: 0 auto; }
|
||||
#appearance-dialog { max-width: 920px; max-height: calc(100svh - 32px); }
|
||||
#appearance-dialog h3 { margin: 0; font-size: 16px; }
|
||||
.appearance-layout { display: grid; grid-template-columns: minmax(0, .75fr) minmax(0, 1.4fr); gap: 24px; margin-top: 24px; align-items: center; }
|
||||
.appearance-preview { overflow: hidden; background: #ffb139; border: 2px solid #242526; }
|
||||
.appearance-preview .fighter-art { aspect-ratio: 260 / 300; }
|
||||
.skin-options { display: flex; gap: 10px; margin: 12px 0 24px; }
|
||||
.skin-options button { flex: 1; min-width: 0; display: flex; align-items: center; justify-content: center; gap: 8px; padding: 10px 8px; font-size: 16px; }
|
||||
.skin-swatch { flex-shrink: 0; width: 22px; height: 22px; border: 1px solid currentColor; background: var(--swatch); }
|
||||
.color-options { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; margin-top: 14px; }
|
||||
body.player-app .color-options button { min-width: 0; padding: 8px 6px 10px; display: flex; flex-direction: column; align-items: center; gap: 7px; background: #fff; color: #242526; box-shadow: 3px 3px 0 #24252630; }
|
||||
.color-swatch { display: block; width: 100%; height: 46px; border: 1px solid #24252660; background: var(--swatch); }
|
||||
.color-name { font-size: 14px; }
|
||||
body.player-app .color-requirement { font-family: var(--ui-body); font-size: 13px; font-weight: 700; line-height: 1.5; word-break: keep-all; }
|
||||
.color-lock { position: absolute; top: 4px; right: 4px; width: 25px; height: 25px; padding: 3px; background: #242526; color: #fff; }
|
||||
.color-lock svg { display: block; width: 100%; height: 100%; }
|
||||
body.player-app :is(.skin-options, .color-options, .hair-options, .equipment-options, .eyewear-options) button[aria-pressed=true] { background: #ffdfb5; color: #242526; box-shadow: inset 0 0 0 2px #ff912b, 3px 3px 0 #242526; }
|
||||
.hair-options { display: flex; gap: 10px; margin: 12px 0 24px; }
|
||||
.hair-options button { flex: 1; min-width: 0; padding: 10px 6px; font-size: 15px; word-break: keep-all; }
|
||||
.equipment-options, .eyewear-options { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; margin-top: 14px; }
|
||||
body.player-app :is(.equipment-options, .eyewear-options) button { display: flex; flex-direction: column; gap: 8px; align-items: center; justify-content: center; padding: 26px 6px 12px; background: #fff; color: #242526; font-size: 15px; }
|
||||
#appearance-dialog .eyewear-heading { margin-top: 24px; }
|
||||
.equipment-state { font-family: var(--ui-body); font-size: 12px; }
|
||||
.appearance-options .equip { margin-top: 16px; width: 100%; }
|
||||
.color-condition, .profile-notice { min-height: 24px; }
|
||||
body.player-app #appearance-dialog .color-condition { color: #242526; font-weight: 700; }
|
||||
.profile-notice:empty { display: none; }
|
||||
#leaderboard-dialog { max-width: 680px; }
|
||||
.leaderboard-content { overflow-x: auto; }
|
||||
.leaderboard-content table { width: 100%; border-collapse: collapse; font-size: 14px; }
|
||||
.leaderboard-content th, .leaderboard-content td { padding: 12px 6px; text-align: right; border-bottom: 1px solid #d2cec4; font-variant-numeric: tabular-nums; }
|
||||
.leaderboard-content th:nth-child(2), .leaderboard-content td:nth-child(2) { text-align: left; overflow-wrap: anywhere; }
|
||||
.leaderboard-content .self-ranking { background: #ffdfae; }
|
||||
body[data-screen=ranked-queue] .app { min-height: 100svh; }
|
||||
@media(max-width:760px) {
|
||||
#home .home-actions > button:not(.primary) { min-height: 54px; font-size: 23px; }
|
||||
#home .home-actions .split button { min-height: 54px; font-size: 20px; padding-inline: 12px; }
|
||||
.lobby-tools { margin-top: 12px; }
|
||||
.lobby-tools button { font-size: 16px; }
|
||||
}
|
||||
@media(max-width:520px) {
|
||||
#home .home-actions .split button { font-size: 18px; gap: 6px; padding-inline: 10px; }
|
||||
#home .home-actions .split svg { flex-basis: 18px; width: 18px; height: 18px; }
|
||||
.skin-options button { font-size: 14px; }
|
||||
.hair-options { gap: 6px; }
|
||||
.hair-options button { font-size: 13px; }
|
||||
.appearance-layout { grid-template-columns: minmax(0, 1fr); gap: 16px; margin-top: 16px; }
|
||||
.appearance-preview { width: 140px; margin: auto; }
|
||||
.appearance-preview .fighter-art { max-height: 160px; }
|
||||
.color-options { gap: 6px; }
|
||||
body.player-app .color-options button { padding: 6px 3px 8px; gap: 6px; }
|
||||
.color-swatch { height: 34px; }
|
||||
.color-lock { width: 21px; height: 21px; top: 2px; right: 2px; }
|
||||
.color-name { font-size: 12px; }
|
||||
body.player-app .color-requirement { font-size: 11px; }
|
||||
.leaderboard-content th, .leaderboard-content td { font-size: 12px; padding: 10px 4px; }
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
import { COSMETIC_COLORS, COSMETIC_EQUIPMENT, EYEWEAR_IDS, HAIR_STYLES, SKIN_TONES, type EquipmentId, type HairStyle } from '../../constants/cosmetics.js';
|
||||
import { RANKED_POLL_MS } from '../../constants/ranked.js';
|
||||
import { appearanceKey, normalizeAppearance, type FighterAppearance } from '../shared/cosmetics.js';
|
||||
import type { Leaderboard, PlayerProfile, RankedStatus } from '../shared/ranked-types.js';
|
||||
import type { ActionId } from '../shared/types.js';
|
||||
import type { LocalMatchState } from '../shared/p2p-types.js';
|
||||
import type { GameApi } from './api.js';
|
||||
import { fighterArt } from './fighter-art.js';
|
||||
|
||||
interface LobbyOptions {
|
||||
api: GameApi;
|
||||
onQueue(): void;
|
||||
onMatch(state: LocalMatchState): void;
|
||||
onHome(): void;
|
||||
onAppearance(appearance: FighterAppearance): void;
|
||||
onError(error: unknown): void;
|
||||
onAvailabilityChange(): void;
|
||||
}
|
||||
|
||||
export function createLobbyFeatures(options: LobbyOptions) {
|
||||
const { api } = options;
|
||||
const actions = document.querySelector('.home-actions')!;
|
||||
const rankedStart = document.createElement('button');
|
||||
rankedStart.id = 'ranked-start'; rankedStart.className = 'primary'; rankedStart.disabled = true;
|
||||
rankedStart.innerHTML = '<span>랭킹 대전</span><span aria-hidden="true">→</span>';
|
||||
actions.prepend(rankedStart);
|
||||
document.getElementById('bot-start')!.classList.remove('primary');
|
||||
const tools = document.createElement('div'); tools.className = 'lobby-tools';
|
||||
tools.innerHTML = '<button id="leaderboard-open" disabled>리더보드</button><button id="appearance-open" disabled>캐릭터·장비</button>';
|
||||
actions.after(tools);
|
||||
const queue = document.createElement('section'); queue.id = 'ranked-queue'; queue.className = 'waiting ranked-queue'; queue.hidden = true;
|
||||
queue.innerHTML = '<div class="queue-fighter" aria-hidden="true"></div><h1>대전 상대 찾는 중</h1><button id="ranked-cancel">매칭 취소</button>';
|
||||
document.getElementById('waiting')!.after(queue);
|
||||
const appearanceDialog = document.createElement('dialog'); appearanceDialog.id = 'appearance-dialog';
|
||||
appearanceDialog.innerHTML = '<div class="dialog-top"><h2>캐릭터·장비</h2><button class="quiet" aria-label="캐릭터·장비 닫기">닫기</button></div><div class="appearance-layout"><div class="appearance-preview" aria-label="외형 미리보기"></div><div class="appearance-options"><h3 id="skin-label">피부</h3><div class="skin-options" role="group" aria-labelledby="skin-label"></div><h3 id="hair-label">머리</h3><div class="hair-options" role="group" aria-labelledby="hair-label"></div><h3 id="color-label">도복 색상</h3><div class="color-options" role="group" aria-labelledby="color-label"></div><p class="color-condition" role="status"></p><h3 id="equipment-label">장비</h3><div class="equipment-options" role="group" aria-labelledby="equipment-label"></div><h3 id="eyewear-label" class="eyewear-heading">안경</h3><div class="eyewear-options" role="group" aria-labelledby="eyewear-label"></div><button class="equip primary">장착</button></div></div><p class="profile-notice" role="status"></p>';
|
||||
const leaderboardDialog = document.createElement('dialog'); leaderboardDialog.id = 'leaderboard-dialog';
|
||||
leaderboardDialog.innerHTML = '<div class="dialog-top"><h2>랭킹 리더보드</h2><button class="quiet" aria-label="리더보드 닫기">닫기</button></div><p class="ranking-self"></p><p class="ranking-order">최대 연승순 · 동률이면 누적 승순</p><div class="leaderboard-content" aria-live="polite"></div>';
|
||||
document.body.append(appearanceDialog, leaderboardDialog);
|
||||
const select = <T extends HTMLElement = HTMLElement>(root: ParentNode, selector: string) => root.querySelector<T>(selector)!;
|
||||
const profileNotice = select(appearanceDialog, '.profile-notice');
|
||||
const equip = select<HTMLButtonElement>(appearanceDialog, '.equip');
|
||||
const appearanceOpen = document.getElementById('appearance-open') as HTMLButtonElement;
|
||||
const leaderboardOpen = document.getElementById('leaderboard-open') as HTMLButtonElement;
|
||||
const cancel = document.getElementById('ranked-cancel') as HTMLButtonElement;
|
||||
let profile: PlayerProfile | undefined, preview: FighterAppearance = normalizeAppearance(undefined);
|
||||
let latest: RankedStatus | undefined, active = false, busy = false, profileBusy = false, timer: ReturnType<typeof setTimeout> | undefined, generation = 0;
|
||||
let profileRevision = 0;
|
||||
const lockIcon = '<span class="color-lock" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none"><rect x="5" y="10" width="14" height="11" rx="1" stroke="currentColor" stroke-width="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3m-4 5v2" stroke="currentColor" stroke-width="2"/></svg></span>';
|
||||
const requirementText = (requirement: { type: 'wins' | 'streak'; value: number }) => `랭킹 ${requirement.type === 'wins' ? '누적' : '최대'} ${requirement.value}${requirement.type === 'wins' ? '승' : '연승'}`;
|
||||
|
||||
function updateProfile(next: PlayerProfile) {
|
||||
const appearanceChanged = !profile || appearanceKey(profile.appearance) !== appearanceKey(next.appearance);
|
||||
profile = next;
|
||||
if (appearanceChanged) {
|
||||
options.onAppearance(next.appearance);
|
||||
select(queue, '.queue-fighter').innerHTML = fighterArt(0, false, next.appearance);
|
||||
}
|
||||
options.onAvailabilityChange();
|
||||
}
|
||||
function renderAppearance() {
|
||||
if (!profile) return;
|
||||
select(appearanceDialog, '.appearance-preview').innerHTML = fighterArt(0, false, preview);
|
||||
select(appearanceDialog, '.skin-options').innerHTML = SKIN_TONES.map(skin => `<button type="button" data-skin="${skin.id}" aria-pressed="${preview.skinTone === skin.id}"><span class="skin-swatch" style="--swatch:${skin.skin}" aria-hidden="true"></span>${skin.name}</button>`).join('');
|
||||
select(appearanceDialog, '.hair-options').innerHTML = HAIR_STYLES.map(hair => `<button type="button" data-hair="${hair.id}" aria-pressed="${preview.hairStyle === hair.id}">${hair.name}</button>`).join('');
|
||||
select(appearanceDialog, '.color-options').innerHTML = COSMETIC_COLORS.map(color => {
|
||||
const unlocked = profile!.unlockedColors.includes(color.id);
|
||||
const requirement = color.requirement;
|
||||
const condition = requirement ? requirementText(requirement) : '기본';
|
||||
return `<button type="button" data-color="${color.id}" aria-pressed="${preview.colorId === color.id}" aria-label="${color.name}, ${condition}${unlocked ? '' : ', 잠김, 미리보기'}"><span class="color-swatch" style="--swatch:${color.suit}" aria-hidden="true"></span>${unlocked ? '' : lockIcon}<span class="color-name">${color.name}</span><small class="color-requirement">${condition}</small></button>`;
|
||||
}).join('');
|
||||
const availableEquipment = profile.unlockedEquipment ?? [];
|
||||
const equipmentButton = (item: typeof COSMETIC_EQUIPMENT[number]) => {
|
||||
const selected = preview.equipment!.includes(item.id), unlocked = availableEquipment.includes(item.id);
|
||||
const state = selected ? unlocked ? '착용' : '미리보기' : '미착용';
|
||||
return `<button type="button" data-equipment="${item.id}" aria-pressed="${selected}" aria-label="${item.name}, ${state}, ${requirementText(item.requirement)}${unlocked ? '' : ', 잠김'}">${unlocked ? '' : lockIcon}<span>${item.name}</span><small class="equipment-state">${state}</small><small class="color-requirement">${requirementText(item.requirement)}</small></button>`;
|
||||
};
|
||||
const isEyewear = (id: EquipmentId) => EYEWEAR_IDS.some(eye => eye === id);
|
||||
select(appearanceDialog, '.equipment-options').innerHTML = COSMETIC_EQUIPMENT.filter(item => !isEyewear(item.id)).map(equipmentButton).join('');
|
||||
select(appearanceDialog, '.eyewear-options').innerHTML = `<button type="button" data-equipment="none" aria-pressed="${!preview.equipment!.some(isEyewear)}">미착용</button>`
|
||||
+ COSMETIC_EQUIPMENT.filter(item => isEyewear(item.id)).map(equipmentButton).join('');
|
||||
const color = COSMETIC_COLORS.find(c => c.id === preview.colorId)!;
|
||||
const unlocked = profile.unlockedColors.includes(color.id);
|
||||
const requirement = color.requirement;
|
||||
select(appearanceDialog, '.color-condition').textContent = !unlocked && requirement
|
||||
? `현재 ${requirement.type === 'wins' ? '누적 ' + profile.stats.wins + '승' : '최대 ' + profile.stats.best + '연승'} / ${requirement.value}${requirement.type === 'wins' ? '승' : '연승'}`
|
||||
: requirement ? '획득한 도복' : '기본 도복';
|
||||
const lockedItems = COSMETIC_EQUIPMENT.filter(item => preview.equipment!.includes(item.id) && !availableEquipment.includes(item.id));
|
||||
const equipped = appearanceKey(preview) === appearanceKey(profile.appearance);
|
||||
const canEquip = unlocked && !lockedItems.length;
|
||||
equip.disabled = profileBusy || !canEquip || equipped;
|
||||
equip.textContent = equipped ? '장착 중' : canEquip ? '장착' : '잠김';
|
||||
}
|
||||
select(appearanceDialog, '.skin-options').addEventListener('click', event => {
|
||||
const button = (event.target as HTMLElement).closest<HTMLButtonElement>('[data-skin]');
|
||||
if (!button || profileBusy) return;
|
||||
const skin = SKIN_TONES.find(skin => skin.id === button.dataset.skin);
|
||||
if (!skin) return;
|
||||
preview = { ...preview, skinTone: skin.id }; renderAppearance();
|
||||
});
|
||||
select(appearanceDialog, '.color-options').addEventListener('click', event => {
|
||||
const button = (event.target as HTMLElement).closest<HTMLButtonElement>('[data-color]');
|
||||
if (!button || profileBusy) return;
|
||||
preview = { ...preview, colorId: button.dataset.color! }; renderAppearance();
|
||||
});
|
||||
select(appearanceDialog, '.hair-options').addEventListener('click', event => {
|
||||
const button = (event.target as HTMLElement).closest<HTMLButtonElement>('[data-hair]');
|
||||
if (!button || profileBusy) return;
|
||||
preview = { ...preview, hairStyle: button.dataset.hair as HairStyle }; renderAppearance();
|
||||
});
|
||||
select(appearanceDialog, '.equipment-options').addEventListener('click', event => {
|
||||
const button = (event.target as HTMLElement).closest<HTMLButtonElement>('[data-equipment]');
|
||||
if (!button || profileBusy) return;
|
||||
const id = button.dataset.equipment as EquipmentId;
|
||||
preview = { ...preview, equipment: preview.equipment!.includes(id) ? preview.equipment!.filter(item => item !== id) : [...preview.equipment!, id] }; renderAppearance();
|
||||
});
|
||||
select(appearanceDialog, '.eyewear-options').addEventListener('click', event => {
|
||||
const button = (event.target as HTMLElement).closest<HTMLButtonElement>('[data-equipment]');
|
||||
if (!button || profileBusy) return;
|
||||
const id = button.dataset.equipment;
|
||||
const equipment = preview.equipment!.filter(item => !EYEWEAR_IDS.some(eye => eye === item));
|
||||
const eyewear = EYEWEAR_IDS.find(eye => eye === id);
|
||||
preview = { ...preview, equipment: eyewear ? [...equipment, eyewear] : equipment }; renderAppearance();
|
||||
});
|
||||
async function saveProfile(input: { appearance: FighterAppearance }) {
|
||||
if (profileBusy) return;
|
||||
profileRevision++;
|
||||
profileBusy = true; profileNotice.textContent = ''; renderAppearance();
|
||||
try {
|
||||
updateProfile(await api.request<PlayerProfile>('/api/profile', 'POST', input));
|
||||
profileNotice.textContent = '저장했습니다.';
|
||||
} catch (error) { profileNotice.textContent = error instanceof Error ? error.message : '저장하지 못했습니다.'; }
|
||||
finally { profileBusy = false; renderAppearance(); }
|
||||
}
|
||||
equip.onclick = () => { void saveProfile({ appearance: { ...preview } }); };
|
||||
select<HTMLButtonElement>(appearanceDialog, '.dialog-top button').onclick = () => appearanceDialog.close();
|
||||
select<HTMLButtonElement>(leaderboardDialog, '.dialog-top button').onclick = () => leaderboardDialog.close();
|
||||
appearanceOpen.onclick = async () => {
|
||||
if (!profile) return;
|
||||
appearanceDialog.showModal(); profileNotice.textContent = '';
|
||||
preview = normalizeAppearance(profile.appearance); renderAppearance();
|
||||
const openingPreviewKey = appearanceKey(preview);
|
||||
const revision = ++profileRevision;
|
||||
try {
|
||||
const next = await api.request<PlayerProfile>('/api/profile');
|
||||
if (revision !== profileRevision) return;
|
||||
if (appearanceKey(preview) === openingPreviewKey) preview = normalizeAppearance(next.appearance);
|
||||
updateProfile(next); renderAppearance();
|
||||
} catch (error) { if (revision === profileRevision) profileNotice.textContent = error instanceof Error ? error.message : '프로필을 불러오지 못했습니다.'; }
|
||||
};
|
||||
leaderboardOpen.onclick = async () => {
|
||||
select(leaderboardDialog, '.ranking-self').textContent = profile ? `내 이름 · ${profile.displayName}` : '';
|
||||
const content = select(leaderboardDialog, '.leaderboard-content'); content.textContent = '불러오는 중'; leaderboardDialog.showModal();
|
||||
try {
|
||||
const board = await api.request<Leaderboard>('/api/ranked/leaderboard');
|
||||
content.replaceChildren();
|
||||
if (!board.entries.length) { content.textContent = '아직 랭킹 기록이 없습니다.'; return; }
|
||||
const table = document.createElement('table');
|
||||
table.innerHTML = '<thead><tr><th>순위</th><th>이름</th><th>최대 연승</th><th>누적 승</th></tr></thead><tbody></tbody>';
|
||||
const rows = [...board.entries];
|
||||
if (board.self && !rows.some(row => row.playerId === board.self!.playerId)) rows.push(board.self);
|
||||
for (const entry of rows) {
|
||||
const row = document.createElement('tr');
|
||||
if (entry.playerId === profile?.playerId) row.className = 'self-ranking';
|
||||
for (const text of [entry.rank, entry.displayName + (entry.playerId === profile?.playerId ? ' (나)' : ''), entry.best, entry.wins]) {
|
||||
const cell = document.createElement('td'); cell.textContent = String(text); row.append(cell);
|
||||
}
|
||||
table.tBodies[0].append(row);
|
||||
}
|
||||
content.append(table);
|
||||
} catch (error) { content.textContent = error instanceof Error ? error.message : '랭킹을 불러오지 못했습니다.'; }
|
||||
};
|
||||
function consume(status: RankedStatus) {
|
||||
if (status.match && latest?.match?.matchId === status.match.matchId && status.match.version < latest.match.version) return;
|
||||
latest = status; updateProfile(status.profile);
|
||||
active = status.status !== 'idle'; rankedStart.disabled = busy || active;
|
||||
options.onAvailabilityChange();
|
||||
if (status.status === 'queued') options.onQueue();
|
||||
else if (status.match) options.onMatch(status.match);
|
||||
else options.onHome();
|
||||
}
|
||||
function schedule() {
|
||||
clearTimeout(timer);
|
||||
if (!active || latest?.match?.phase === 'finished') return;
|
||||
const currentGeneration = generation;
|
||||
timer = setTimeout(async () => {
|
||||
try {
|
||||
const status = await api.request<RankedStatus>('/api/ranked/status');
|
||||
if (currentGeneration !== generation) return;
|
||||
consume(status);
|
||||
} catch (error) { if (currentGeneration === generation) options.onError(error); }
|
||||
finally { if (currentGeneration === generation) schedule(); }
|
||||
}, RANKED_POLL_MS);
|
||||
}
|
||||
async function start() {
|
||||
if (busy || active) return;
|
||||
busy = true; rankedStart.disabled = true; generation++;
|
||||
options.onAvailabilityChange();
|
||||
try { consume(await api.request<RankedStatus>('/api/ranked/queue', 'POST', {})); schedule(); }
|
||||
catch (error) { options.onError(error); }
|
||||
finally { busy = false; options.onAvailabilityChange(); }
|
||||
}
|
||||
async function leave() {
|
||||
if (busy) return;
|
||||
busy = true; cancel.disabled = true; generation++; clearTimeout(timer);
|
||||
try {
|
||||
const status = latest?.match
|
||||
? await api.request<RankedStatus>('/api/ranked/leave', 'POST', { matchId: latest.match.matchId })
|
||||
: await api.request<RankedStatus>('/api/ranked/cancel', 'POST', {});
|
||||
consume(status);
|
||||
} catch (error) { options.onError(error); }
|
||||
finally { busy = false; cancel.disabled = false; options.onAvailabilityChange(); schedule(); }
|
||||
}
|
||||
rankedStart.onclick = () => { void start(); };
|
||||
cancel.onclick = () => { void leave(); };
|
||||
return {
|
||||
get active() { return active; },
|
||||
get busy() { return busy; },
|
||||
setReady(ready: boolean) { rankedStart.disabled = !ready || !profile || busy || active; appearanceOpen.disabled = !ready || !profile || busy || active; leaderboardOpen.disabled = !ready || !profile || busy || active; },
|
||||
async initialize(restoreRanked: boolean) {
|
||||
generation++; clearTimeout(timer);
|
||||
if (!restoreRanked) { updateProfile(await api.request<PlayerProfile>('/api/profile')); return false; }
|
||||
const status = await api.request<RankedStatus>('/api/ranked/status');
|
||||
updateProfile(status.profile); latest = status; active = status.status !== 'idle';
|
||||
if (active) { consume(status); schedule(); }
|
||||
return active;
|
||||
},
|
||||
async submitAction(action: ActionId) {
|
||||
if (!latest?.match) return false;
|
||||
const { matchId, turnId } = latest.match;
|
||||
try {
|
||||
const status = await api.request<RankedStatus>('/api/ranked/action', 'POST', { matchId, turnId, action });
|
||||
if (!active || latest?.match?.matchId !== matchId) return false;
|
||||
consume(status); return status.match?.ownAction === action;
|
||||
} catch (error) { options.onError(error); return false; }
|
||||
},
|
||||
leave,
|
||||
async rematch() { await leave(); if (!active) await start(); },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
import { io, type Socket } from 'socket.io-client';
|
||||
import { ACTIONS, ACTION_IDS } from '../../constants/gameplay.js';
|
||||
import { GUEST_TOKEN_KEY } from '../../constants/session.js';
|
||||
import { GAME_CHECKPOINT_KEY, GAME_ROOM_KEY } from '../../constants/recording.js';
|
||||
import { ROOM_POLL_MS } from '../../constants/webrtc.js';
|
||||
import { GAME_NAME } from '../../constants/branding.js';
|
||||
import { HOME_TELEPORT_APPEARANCE } from '../../constants/home.js';
|
||||
import type { Mode, PlayerStats } from '../shared/types.js';
|
||||
import type { ActiveMatch, IceConfiguration, LocalMatchState, PeerRoom } from '../shared/p2p-types.js';
|
||||
import { createClientAnalytics } from './analytics.js';
|
||||
import { ApiClientError, createGameApi } from './api.js';
|
||||
import { createMatchController, type ControllerCheckpoint, type MatchController } from './match-controller.js';
|
||||
import { createPeer } from './peer.js';
|
||||
import { createResultReporter, type SessionStorage } from './result-reporter.js';
|
||||
import { createMatchView } from './match-view.js';
|
||||
import { fighterArt } from './fighter-art.js';
|
||||
import { createLobbyFeatures } from './lobby-features.js';
|
||||
import './game.css';
|
||||
import './match.css';
|
||||
import './home.css';
|
||||
import './ui-theme.css';
|
||||
import './lobby-features.css';
|
||||
|
||||
const el = <T extends HTMLElement = HTMLElement>(id: string) => document.getElementById(id)! as T;
|
||||
document.title = GAME_NAME;
|
||||
const homeFighters = el('home-fighters');
|
||||
homeFighters.innerHTML = '<div class="home-fighter home-fighter-left">' + fighterArt(0) + '</div><div class="home-fighter home-fighter-teleport">' + fighterArt(0, false, HOME_TELEPORT_APPEARANCE) + '</div><div class="home-fighter home-fighter-right">' + fighterArt(1) + '</div>';
|
||||
homeFighters.classList.add('home-entering');
|
||||
homeFighters.addEventListener('animationend', event => {
|
||||
if (event.animationName === 'home-teleport-in') homeFighters.classList.remove('home-entering');
|
||||
});
|
||||
el('waiting-fighter').innerHTML = fighterArt(0);
|
||||
const memory = new Map<string, string>();
|
||||
let storageAvailable = true, ready = false, pending = false, booting = false;
|
||||
let lobby: ReturnType<typeof createLobbyFeatures> | undefined;
|
||||
const storage: SessionStorage = {
|
||||
getItem(key) { try { return sessionStorage.getItem(key) ?? memory.get(key) ?? null; } catch { storageAvailable = false; return memory.get(key) ?? null; } },
|
||||
setItem(key, value) { memory.set(key, value); try { sessionStorage.setItem(key, value); } catch { storageAvailable = false; } },
|
||||
removeItem(key) { memory.delete(key); try { sessionStorage.removeItem(key); } catch { storageAvailable = false; } },
|
||||
};
|
||||
const api = createGameApi(storage);
|
||||
const analytics = createClientAnalytics({
|
||||
events: events => api.request('/api/analytics/events', 'POST', { events }),
|
||||
activity: visible => api.request('/api/analytics/activity', 'POST', { visible }),
|
||||
});
|
||||
let reporter: ReturnType<typeof createResultReporter>;
|
||||
let controller: MatchController | undefined, view: ReturnType<typeof createMatchView> | undefined;
|
||||
let currentMatch: LocalMatchState | undefined, currentRoom: PeerRoom | undefined, currentScreen = '';
|
||||
let waitingSocket: Socket | undefined, roomPoll: ReturnType<typeof setTimeout> | undefined;
|
||||
let startingMatch: string | undefined, startedEvent: string | undefined, finishedEvent: string | undefined;
|
||||
function notice(message = '') { el('notice').textContent = message; }
|
||||
function connection(message: string, connected: boolean) { el('connection').textContent = message; el('connection').classList.toggle('connected', connected); }
|
||||
function controls() {
|
||||
['bot-start', 'room-create', 'join-open'].forEach(id => { el<HTMLButtonElement>(id).disabled = !ready || pending || !!lobby?.busy || !!lobby?.active; });
|
||||
lobby?.setReady(ready && !pending);
|
||||
}
|
||||
function errorNotice(error: unknown) {
|
||||
notice(error instanceof ApiClientError ? error.message : '연결을 확인하고 다시 시도하세요.');
|
||||
el('reconnect').hidden = false;
|
||||
}
|
||||
async function perform(action: () => Promise<void>, event?: string) {
|
||||
if (pending || !ready) return;
|
||||
pending = true; controls(); notice(); if (event) analytics.click(event);
|
||||
try { await action(); } catch (error) { errorNotice(error); }
|
||||
finally { pending = false; controls(); }
|
||||
}
|
||||
function setScreen(screen: 'home' | 'join' | 'waiting' | 'match' | 'result' | 'ranked-queue') {
|
||||
if (currentScreen !== screen) { analytics.screen(screen); currentScreen = screen; notice(); }
|
||||
el('home').hidden = !['home', 'join'].includes(screen); el('waiting').hidden = screen !== 'waiting'; el('match').hidden = !['match', 'result'].includes(screen);
|
||||
el('join-form').hidden = screen !== 'join'; document.body.dataset.screen = screen;
|
||||
el('ranked-queue').hidden = screen !== 'ranked-queue';
|
||||
if (!['match', 'result'].includes(screen)) { view?.destroy(); view = undefined; currentMatch = undefined; }
|
||||
}
|
||||
function stats(data: PlayerStats) {
|
||||
el('stats').innerHTML = (['bot', 'friend'] as const).map(mode => {
|
||||
const s = data[mode];
|
||||
return '<div class="stat-mode"><span>' + (mode === 'bot' ? '봇 대전' : '친구 대전') + '</span><div><span>현재 <strong>' + s.current + '</strong></span><span>최고 <strong>' + s.best + '</strong></span></div></div>';
|
||||
}).join('');
|
||||
}
|
||||
function checkpoint(): ControllerCheckpoint | undefined {
|
||||
try { const saved = JSON.parse(storage.getItem(GAME_CHECKPOINT_KEY) ?? 'null'); return saved?.gameSessionId === api.session.gameSessionId ? saved : undefined; }
|
||||
catch { return undefined; }
|
||||
}
|
||||
function rememberRoom(room?: PeerRoom) {
|
||||
currentRoom = room;
|
||||
if (room) storage.setItem(GAME_ROOM_KEY, JSON.stringify({ gameSessionId: api.session.gameSessionId, room })); else storage.removeItem(GAME_ROOM_KEY);
|
||||
}
|
||||
function stopWaiting() { waitingSocket?.removeAllListeners(); waitingSocket?.disconnect(); waitingSocket = undefined; clearTimeout(roomPoll); }
|
||||
function signaling(room: PeerRoom) {
|
||||
return io('/signal', { autoConnect: false, reconnection: false, forceNew: true, auth: { gameSessionId: api.session.gameSessionId, token: api.session.token, roomId: room.id } });
|
||||
}
|
||||
function showMatch(state: LocalMatchState) {
|
||||
if (state.phase === 'finished' && state.mode === 'friend' && currentRoom?.currentMatchId === state.matchId) {
|
||||
state = { ...state, rematch: [...currentRoom.rematch], opponentLeft: currentRoom.status === 'closed' };
|
||||
}
|
||||
currentMatch = state;
|
||||
if (currentScreen !== 'result' || state.phase !== 'finished') setScreen('match');
|
||||
if (!view) view = createMatchView(el('match'), action => currentMatch?.ranked ? lobby!.submitAction(action) : controller?.submitAction(action) ?? Promise.resolve(false),
|
||||
() => { void perform(() => currentMatch?.ranked ? lobby!.rematch() : controller ? controller.rematch() : rematch(), 'rematch'); },
|
||||
() => { void perform(leave); }, () => setScreen('result'), () => el('rules-open').click());
|
||||
view.update(state);
|
||||
if (state.finished?.saved && !state.ranked) stats(state.finished.stats);
|
||||
if (state.saveStatus === 'failed') el('reconnect').hidden = false;
|
||||
if (state.phase === 'finished') {
|
||||
connection('대전 종료', true);
|
||||
if (finishedEvent !== state.matchId && state.finished) {
|
||||
finishedEvent = state.matchId;
|
||||
analytics.gameFinished(state.matchId, state.mode, state.aborted ? 'aborted' : state.finished.winnerIndex === null ? 'draw' : state.finished.winnerIndex === state.selfIndex ? 'win' : 'loss', state.finished.reason);
|
||||
}
|
||||
} else connection(state.mode === 'friend' && !state.opponentPresent ? '연결 복구 중' : Number.isFinite(state.startsAt) ? '대전 중' : '연결 중', state.opponentPresent && Number.isFinite(state.startsAt));
|
||||
}
|
||||
async function launch(mode: Mode, room?: PeerRoom, existing?: ActiveMatch) {
|
||||
const matchId = existing?.matchId ?? room?.currentMatchId ?? crypto.randomUUID();
|
||||
if (startingMatch === matchId || controller?.view().matchId === matchId) return;
|
||||
startingMatch = matchId; stopWaiting();
|
||||
let active = existing;
|
||||
try {
|
||||
const session = await api.sync();
|
||||
if (!active) {
|
||||
if (session.activeMatch) {
|
||||
if (session.activeMatch.matchId !== matchId) throw new ApiClientError(409, 'MATCH_PENDING', '이전 대전 기록을 저장한 뒤 다시 시도하세요.');
|
||||
active = session.activeMatch;
|
||||
} else {
|
||||
const input = { requestId: crypto.randomUUID(), matchId, mode, ...(room ? { roomId: room.id } : {}), expectedPreviousSeq: session.lastAppliedSeq };
|
||||
try { active = await api.registerMatch(input); }
|
||||
catch (error) { const recovered = await api.sync(); if (recovered.activeMatch?.matchId !== matchId) throw error; active = recovered.activeMatch; }
|
||||
}
|
||||
}
|
||||
controller?.destroy(); controller = undefined; view?.destroy(); view = undefined;
|
||||
const saved = checkpoint();
|
||||
let peer: ReturnType<typeof createPeer> | undefined;
|
||||
const savedFinal = saved?.activeMatch.matchId === matchId && saved.checkpoint.state.phase === 'finished' && (saved.confirmed?.state.phase === 'finished' || saved.checkpoint.state.aborted);
|
||||
if (mode === 'friend' && !savedFinal) {
|
||||
if (!room) throw new Error('ROOM_REQUIRED');
|
||||
const peerRoom = room;
|
||||
const ice = () => api.request<IceConfiguration>('/api/rtc-config?roomId=' + encodeURIComponent(peerRoom.id));
|
||||
peer = createPeer({ matchId, roomId: peerRoom.id, slot: peerRoom.selfIndex,
|
||||
epoch: saved?.activeMatch.matchId === matchId ? saved.epoch : 0,
|
||||
iceServers: (await ice()).iceServers, refreshIce: async () => (await ice()).iceServers,
|
||||
createSignaling: () => signaling(peerRoom) });
|
||||
}
|
||||
controller = createMatchController({ activeMatch: active!, gameSessionId: api.session.gameSessionId, selfIndex: room?.selfIndex ?? 0,
|
||||
reporter, storage, peer, onRematch: rematch });
|
||||
controller.subscribe(showMatch);
|
||||
await controller.start();
|
||||
if (startedEvent !== matchId && !controller.view().aborted) { startedEvent = matchId; analytics.gameStarted(matchId, mode); }
|
||||
el('reconnect').hidden = true;
|
||||
} catch (error) {
|
||||
if (active && !controller) await reporter.submit({ ...active, outcome: 'aborted', reason: 'connection_failed' });
|
||||
throw error;
|
||||
} finally { startingMatch = undefined; }
|
||||
}
|
||||
async function acceptRoom(room: PeerRoom) {
|
||||
rememberRoom(room);
|
||||
if (room.status === 'closed') { stopWaiting(); rememberRoom(); setScreen('home'); notice('방이 종료되었습니다.'); return; }
|
||||
if (room.status === 'paired') { await launch('friend', room); return; }
|
||||
setScreen('waiting'); el('waiting-code').textContent = room.code;
|
||||
el('room-expiry').textContent = new Date(room.expiresAt).toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit' }) + ' 만료';
|
||||
stopWaiting(); waitingSocket = signaling(room);
|
||||
waitingSocket.on('room:state', (next: PeerRoom) => { if (next.status !== 'waiting') void acceptRoom(next).catch(errorNotice); });
|
||||
waitingSocket.on('connect_error', errorNotice);
|
||||
waitingSocket.on('disconnect', () => { connection('연결 끊김', false); el('reconnect').hidden = false; });
|
||||
waitingSocket.connect();
|
||||
}
|
||||
async function rematch() {
|
||||
if (!currentMatch?.finished?.saved) return;
|
||||
if (currentMatch.mode === 'bot') { storage.removeItem(GAME_CHECKPOINT_KEY); await launch('bot'); return; }
|
||||
if (!currentRoom) return;
|
||||
const room = currentRoom;
|
||||
const next = await api.request<PeerRoom>('/api/rooms/' + room.id + '/rematch', 'POST', { round: room.round, requestId: crypto.randomUUID() });
|
||||
rememberRoom(next);
|
||||
if (currentMatch) showMatch(currentMatch);
|
||||
if (next.round > room.round) { storage.removeItem(GAME_CHECKPOINT_KEY); await launch('friend', next); return; }
|
||||
watchRematch(room);
|
||||
}
|
||||
function watchRematch(room: PeerRoom) {
|
||||
clearTimeout(roomPoll);
|
||||
const poll = async () => {
|
||||
if (currentRoom?.id !== room.id) return;
|
||||
try {
|
||||
const updated = await api.request<PeerRoom>('/api/rooms/' + room.id);
|
||||
rememberRoom(updated);
|
||||
if (currentMatch) showMatch(currentMatch);
|
||||
if (updated.status === 'closed') return;
|
||||
if (updated.round > room.round) { storage.removeItem(GAME_CHECKPOINT_KEY); await launch('friend', updated); return; }
|
||||
} catch (error) { errorNotice(error); if (error instanceof ApiClientError && error.status < 500) return; }
|
||||
roomPoll = setTimeout(() => { void poll(); }, ROOM_POLL_MS);
|
||||
};
|
||||
roomPoll = setTimeout(() => { void poll(); }, ROOM_POLL_MS);
|
||||
}
|
||||
async function leave() {
|
||||
if (currentMatch?.ranked || lobby?.active) { await lobby!.leave(); return; }
|
||||
stopWaiting();
|
||||
if (controller && controller.view().phase !== 'finished') await controller.leave();
|
||||
if (currentRoom) await api.leaveRoom(currentRoom.id);
|
||||
controller?.destroy(); controller = undefined; rememberRoom(); storage.removeItem(GAME_CHECKPOINT_KEY);
|
||||
stats((await api.sync()).stats); setScreen('home'); connection('접속됨', true);
|
||||
}
|
||||
async function boot() {
|
||||
if (booting) return; booting = true; ready = false; controls(); connection('접속 중', false);
|
||||
try {
|
||||
let token: string | undefined;
|
||||
try { token = localStorage.getItem(GUEST_TOKEN_KEY) ?? undefined; } catch { storageAvailable = false; }
|
||||
const session = await api.ensureSession(token);
|
||||
if (session.guestToken) try { localStorage.setItem(GUEST_TOKEN_KEY, session.guestToken); } catch { storageAvailable = false; }
|
||||
reporter?.destroy(); reporter = createResultReporter({ gameSessionId: session.gameSessionId, record: value => api.record(value) }, storage);
|
||||
analytics.ready(); stats(session.stats); connection('접속됨', true); el('reconnect').hidden = true;
|
||||
await reporter.resumePending(); await api.sync();
|
||||
if (await api.resumeRoomLeave()) { rememberRoom(); storage.removeItem(GAME_CHECKPOINT_KEY); }
|
||||
const saved = checkpoint();
|
||||
try { const stored = JSON.parse(storage.getItem(GAME_ROOM_KEY) ?? 'null'); if (stored?.gameSessionId === session.gameSessionId) currentRoom = stored.room; } catch { rememberRoom(); }
|
||||
const active = api.session.activeMatch;
|
||||
try {
|
||||
if (await lobby?.initialize(!active && !currentRoom)) { ready = true; return; }
|
||||
} catch (error) { errorNotice(error); }
|
||||
if (active) {
|
||||
if (active.mode === 'friend' && saved?.activeMatch.matchId !== active.matchId) {
|
||||
await reporter.submit({ ...active, outcome: 'aborted', reason: 'connection_failed' });
|
||||
if (active.roomId) await api.leaveRoom(active.roomId);
|
||||
rememberRoom();
|
||||
} else if (active.mode === 'friend') {
|
||||
try { rememberRoom(await api.request<PeerRoom>('/api/rooms/' + active.roomId)); }
|
||||
catch (error) {
|
||||
if (!(error instanceof ApiClientError) || ![404, 410].includes(error.status)) throw error;
|
||||
await reporter.submit({ ...active, outcome: 'aborted', reason: 'connection_failed' }); rememberRoom();
|
||||
}
|
||||
}
|
||||
if (api.session.activeMatch) await launch(active.mode, currentRoom, active);
|
||||
else { stats(api.session.stats); setScreen('home'); }
|
||||
} else if (saved?.checkpoint.state.phase === 'finished') {
|
||||
const state = saved.checkpoint.state;
|
||||
state.appearances = saved.activeMatch.appearances;
|
||||
state.saveStatus = 'saved'; state.finished!.saved = true; state.finished!.stats = api.session.stats;
|
||||
state.clockNow = Date.now(); state.reveal = undefined; state.finished!.revealEndsAt = state.clockNow;
|
||||
state.opponentPresent = state.mode === 'bot' || !!currentRoom;
|
||||
showMatch(state);
|
||||
if (state.mode === 'friend' && currentRoom) {
|
||||
try {
|
||||
const room = await api.request<PeerRoom>('/api/rooms/' + currentRoom.id); rememberRoom(room);
|
||||
if (room.status === 'paired' && room.currentMatchId !== state.matchId) { storage.removeItem(GAME_CHECKPOINT_KEY); await launch('friend', room); }
|
||||
else { showMatch(state); if (room.status === 'paired' && room.rematch[room.selfIndex]) watchRematch(room); }
|
||||
} catch (error) {
|
||||
if (!(error instanceof ApiClientError) || ![404, 410].includes(error.status)) throw error;
|
||||
rememberRoom(); state.opponentLeft = true; showMatch(state);
|
||||
}
|
||||
}
|
||||
} else if (currentRoom) {
|
||||
try { await acceptRoom(await api.request<PeerRoom>('/api/rooms/' + currentRoom.id)); }
|
||||
catch (error) { rememberRoom(); setScreen('home'); errorNotice(error); }
|
||||
} else setScreen(new URLSearchParams(location.search).has('room') ? 'join' : 'home');
|
||||
ready = true;
|
||||
if (!storageAvailable) { notice('저장소를 사용할 수 없어 새로고침 시 기록을 복원할 수 없습니다.'); analytics.error('storage_unavailable', currentScreen || 'home'); }
|
||||
} catch (error) { errorNotice(error); }
|
||||
finally { booting = false; controls(); }
|
||||
}
|
||||
el('bot-start').onclick = () => { void perform(() => launch('bot'), 'bot_start'); };
|
||||
el('room-create').onclick = () => { void perform(async () => acceptRoom(await api.createRoom()), 'room_create'); };
|
||||
el('join-open').onclick = () => { setScreen('join'); el<HTMLInputElement>('room-code').focus(); };
|
||||
el('join-close').onclick = () => setScreen('home');
|
||||
el('join-form').onsubmit = e => { e.preventDefault(); void perform(async () => acceptRoom(await api.request<PeerRoom>('/api/rooms/join', 'POST', { code: el<HTMLInputElement>('room-code').value.trim().toUpperCase(), requestId: crypto.randomUUID() })), 'room_join'); };
|
||||
el('waiting-leave').onclick = () => { void perform(leave); };
|
||||
el('reconnect').onclick = () => {
|
||||
if (controller?.view().phase === 'finished') { void controller.retrySave(); return; }
|
||||
if (controller) return;
|
||||
void boot();
|
||||
};
|
||||
async function copy(link: boolean) {
|
||||
if (!currentRoom) return;
|
||||
const url = new URL(location.origin); url.searchParams.set('room', currentRoom.code);
|
||||
try { await navigator.clipboard.writeText(link ? url.toString() : currentRoom.code); notice('복사했습니다.'); }
|
||||
catch { notice('복사하지 못했습니다. 표시된 코드를 전달하세요.'); }
|
||||
}
|
||||
el('copy-code').onclick = () => { void copy(false); }; el('copy-link').onclick = () => { void copy(true); };
|
||||
el('rules-list').innerHTML = ACTION_IDS.map(id => '<div class="rule-row"><strong>' + ACTIONS[id].name + '</strong><span>기 ' + ACTIONS[id].cost + ' 소모 · ' + ACTIONS[id].effect + '</span></div>').join('');
|
||||
for (const name of ['rules', 'privacy', 'contact']) el(name + '-open').onclick = () => el<HTMLDialogElement>(name + '-dialog').showModal();
|
||||
document.querySelectorAll<HTMLButtonElement>('[data-close]').forEach(b => { b.onclick = () => b.closest('dialog')!.close(); });
|
||||
const invitation = new URLSearchParams(location.search).get('room');
|
||||
if (invitation && /^[A-Z0-9]{6}$/i.test(invitation)) el<HTMLInputElement>('room-code').value = invitation.toUpperCase();
|
||||
lobby = createLobbyFeatures({
|
||||
api,
|
||||
onQueue: () => { setScreen('ranked-queue'); connection('상대 찾는 중', true); },
|
||||
onMatch: state => showMatch(state),
|
||||
onHome: () => { setScreen('home'); connection('접속됨', true); notice(); el('reconnect').hidden = true; },
|
||||
onAppearance: appearance => {
|
||||
el('waiting-fighter').innerHTML = fighterArt(0, false, appearance);
|
||||
},
|
||||
onError: errorNotice,
|
||||
onAvailabilityChange: controls,
|
||||
});
|
||||
void boot();
|
||||
@@ -0,0 +1,90 @@
|
||||
import { GAME_CHECKPOINT_KEY } from '../../constants/recording.js';
|
||||
import { createMatchEngine } from '../shared/match-engine.js';
|
||||
import type { ActiveMatch, LocalMatchState, MatchCheckpoint, RecordAck, ResultSubmission } from '../shared/p2p-types.js';
|
||||
import type { ActionId } from '../shared/types.js';
|
||||
import type { SessionStorage } from './result-reporter.js';
|
||||
import { createFriendMatch, type GamePeer } from './friend-match.js';
|
||||
|
||||
export interface GameClock { now(): number; setTimeout(fn: () => void, ms: number): any; clearTimeout(id: any): void }
|
||||
export interface ControllerConfig {
|
||||
activeMatch: ActiveMatch; gameSessionId: string; selfIndex: 0 | 1;
|
||||
reporter: { submit(data: ResultSubmission): Promise<RecordAck> }; storage: SessionStorage;
|
||||
clock?: GameClock; random?: () => number; onRematch?: () => Promise<void>;
|
||||
peer?: GamePeer;
|
||||
}
|
||||
export interface ControllerCheckpoint { gameSessionId: string; activeMatch: ActiveMatch; checkpoint: MatchCheckpoint; epoch: number; confirmed?: MatchCheckpoint }
|
||||
const defaultClock: GameClock = { now: Date.now, setTimeout: (fn, ms) => setTimeout(fn, ms), clearTimeout: id => clearTimeout(id) };
|
||||
export function createMatchController(config: ControllerConfig) {
|
||||
const clock = config.clock ?? defaultClock;
|
||||
let offset = 0;
|
||||
const engine = createMatchEngine({ ...config.activeMatch, selfIndex: config.selfIndex, clock: { now: () => clock.now() + offset }, random: config.random });
|
||||
let friend: ReturnType<typeof createFriendMatch> | undefined;
|
||||
const listeners = new Set<(state: LocalMatchState) => void>();
|
||||
let timer: any, destroyed = false, started = false, recording = false;
|
||||
function publish() {
|
||||
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));
|
||||
for (const listener of listeners) listener({ ...engine.view(), appearances: config.activeMatch.appearances });
|
||||
}
|
||||
async function record() {
|
||||
const state = engine.view(); if (!state.finished || state.finished.saved || recording || destroyed) return;
|
||||
recording = true;
|
||||
const outcome = state.aborted ? 'aborted' : state.finished.winnerIndex === null ? 'draw' : state.finished.winnerIndex === config.selfIndex ? 'win' : 'loss';
|
||||
try {
|
||||
const ack = await config.reporter.submit({ ...config.activeMatch, outcome, reason: state.finished.reason });
|
||||
if (!destroyed) { engine.saved(ack.stats); publish(); }
|
||||
} catch { if (!destroyed) { engine.saveFailed(); publish(); } }
|
||||
finally { recording = false; }
|
||||
}
|
||||
function tick() {
|
||||
if (destroyed) return;
|
||||
clock.clearTimeout(timer);
|
||||
let state = engine.view();
|
||||
if (state.phase === 'countdown' && clock.now() >= state.startsAt) engine.beginTurn(state.startsAt);
|
||||
else if (state.phase === 'revealing' && clock.now() >= state.reveal!.revealEndsAt) engine.beginTurn(state.reveal!.revealEndsAt);
|
||||
state = engine.view();
|
||||
if (state.phase === 'selecting' && clock.now() >= state.deadline) engine.resolve([state.ownAction, engine.botAction()]);
|
||||
publish(); state = engine.view();
|
||||
if (state.phase === 'finished') { void record(); return; }
|
||||
const next = state.phase === 'countdown' ? state.startsAt : state.phase === 'selecting' ? state.deadline : state.reveal!.revealEndsAt;
|
||||
timer = clock.setTimeout(tick, Math.max(0, next - clock.now()));
|
||||
}
|
||||
return {
|
||||
async start() {
|
||||
if (started) return; started = true;
|
||||
let saved: ControllerCheckpoint | undefined;
|
||||
try { saved = JSON.parse(config.storage.getItem(GAME_CHECKPOINT_KEY) ?? 'null') ?? undefined; } catch { /* new game */ }
|
||||
if (saved?.gameSessionId !== config.gameSessionId || saved.activeMatch.matchId !== config.activeMatch.matchId) saved = undefined;
|
||||
if (saved) engine.restore(saved.checkpoint);
|
||||
if (saved?.checkpoint.state.phase === 'finished' && (config.activeMatch.mode === 'bot' || saved.confirmed?.state.phase === 'finished' || saved.checkpoint.state.aborted)) {
|
||||
publish(); await record(); return;
|
||||
}
|
||||
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 });
|
||||
await friend.start(); return;
|
||||
}
|
||||
if (!saved) engine.start();
|
||||
tick();
|
||||
},
|
||||
async submitAction(action: ActionId) { if (friend?.recovering) return false; const accepted = engine.lockAction(action); if (accepted) publish(); return accepted; },
|
||||
async leave() {
|
||||
if (friend) { await friend.leave(); await record(); return; }
|
||||
clock.clearTimeout(timer);
|
||||
if (engine.view().phase !== 'finished') engine.finish(config.selfIndex === 0 ? 1 : 0, 'forfeit');
|
||||
publish(); await record();
|
||||
},
|
||||
async rematch() {
|
||||
if (!engine.view().finished?.saved) return;
|
||||
await config.onRematch?.();
|
||||
if (!destroyed) friend?.requestRematch();
|
||||
},
|
||||
view: () => ({ ...engine.view(), appearances: config.activeMatch.appearances }),
|
||||
subscribe(listener: (state: LocalMatchState) => void) { listeners.add(listener); listener({ ...engine.view(), appearances: config.activeMatch.appearances }); return () => { listeners.delete(listener); }; },
|
||||
retrySave: record,
|
||||
destroy() { destroyed = true; clock.clearTimeout(timer); friend?.destroy(); listeners.clear(); },
|
||||
};
|
||||
}
|
||||
export type MatchController = ReturnType<typeof createMatchController>;
|
||||
@@ -0,0 +1,142 @@
|
||||
import { ACTIONS, ACTION_IDS, MATCH_COUNTDOWN_MS, TURN_DURATION_MS } 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';
|
||||
import { createActionRenderer } from './action-renderer.js';
|
||||
import { actionIcon, ashPileArt, checkIcon, fighterArt } from './fighter-art.js';
|
||||
import { createFinishCutscene } from './finish-cutscene.js';
|
||||
|
||||
const fighter = (i: number, appearance?: FighterAppearance) => `<div class="fighter" data-fighter="${i}">
|
||||
<div class="avatar"><div class="aura"></div><div class="motion-trails"><i></i><i></i><i></i></div><div class="character-direction">${fighterArt(i, false, appearance)}</div>${ashPileArt()}<div class="projectile"></div><div class="shield"></div><div class="impact"></div></div>
|
||||
<div class="fighter-caption"><span class="choice-status"></span><span class="action-label"></span><span class="energy-change" aria-hidden="true"></span></div>
|
||||
</div>`;
|
||||
|
||||
export function createMatchView(container: HTMLElement, onAction: (action: ActionId) => Promise<boolean>, onRematch: () => void, onLeave: () => void, onResult: () => void = () => {}, onRules: () => void = () => {}, appearances?: [FighterAppearance, FighterAppearance]) {
|
||||
container.innerHTML = `<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="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="match-result" hidden><div class="result-stats"></div><p class="save-status" role="status"></p><div class="split"><button class="rematch primary">다시 대전</button><button class="home-return">시작 화면</button></div><p class="rematch-status" role="status"></p></div>`;
|
||||
const arena = container.querySelector<HTMLElement>('.arena')!;
|
||||
const renderer = createActionRenderer(arena);
|
||||
const cutscene = createFinishCutscene(() => { tick(); el('.result-banner h2').focus({ preventScroll: true }); }, appearances);
|
||||
const appearanceSignatures = [0, 1].map(i => appearanceKey(appearances?.[i]));
|
||||
let current: LocalMatchState | undefined, offset = 0, busy = false, lastId = '', resultReported = '', submission = 0;
|
||||
const el = <T extends HTMLElement = HTMLElement>(selector: string) => container.querySelector<T>(selector)!;
|
||||
el('.result-banner h2').tabIndex = -1;
|
||||
const buttons = Array.from(container.querySelectorAll<HTMLButtonElement>('[data-action]'));
|
||||
buttons.forEach(button => button.addEventListener('click', async () => {
|
||||
if (busy || !current || current.phase !== 'selecting' || current.ownAction || Date.now() + offset >= current.deadline) return;
|
||||
const action = button.dataset.action as ActionId;
|
||||
if (current.energy[current.selfIndex] < ACTIONS[action].cost) return;
|
||||
busy = true; tick();
|
||||
const submittedMatch = current.matchId, submittedTurn = current.turnId, request = ++submission;
|
||||
try {
|
||||
const accepted = await onAction(action);
|
||||
if (request !== submission || !current) return;
|
||||
if (accepted && current.matchId === submittedMatch && current.turnId === submittedTurn && current.phase === 'selecting' && !current.ownAction) current.ownAction = action;
|
||||
} finally {
|
||||
if (request === submission) { busy = false; tick(); }
|
||||
}
|
||||
}));
|
||||
el('.leave').onclick = onLeave; el('.home-return').onclick = onLeave; el('.rematch').onclick = onRematch;
|
||||
el('.match-rules').onclick = () => {
|
||||
el<HTMLDetailsElement>('.match-menu').open = false;
|
||||
el('.match-menu summary').focus();
|
||||
onRules();
|
||||
};
|
||||
function tick() {
|
||||
if (!current) return;
|
||||
const now = Date.now() + offset, s = current;
|
||||
const revealing = !!s.reveal && now < s.reveal.revealEndsAt;
|
||||
const resultVisible = s.phase === 'finished' && !revealing;
|
||||
const phase = revealing ? 'revealing' : resultVisible ? 'finished' : s.phase;
|
||||
container.dataset.phase = phase;
|
||||
container.dataset.locked = String(!!s.ownAction);
|
||||
const finishing = arena.dataset.outcome === 'hit';
|
||||
el('.player-controls').hidden = finishing || resultVisible;
|
||||
el('.timing').hidden = finishing || resultVisible;
|
||||
el('.match-result').hidden = !resultVisible;
|
||||
el('.result-banner').hidden = !resultVisible;
|
||||
el('.mode-label').textContent = s.ranked ? '랭킹 대전' : s.mode === 'bot' ? '봇 대전' : '친구 대전';
|
||||
el('.rematch').textContent = s.ranked ? '다시 매칭' : '다시 대전';
|
||||
el('.turn-heading').textContent = s.phase === 'countdown' ? '대전 준비' : resultVisible ? '대전 종료' : `${s.turnId} 턴`;
|
||||
const remaining = s.phase === 'countdown' ? Math.max(0, s.startsAt - now) : Math.max(0, s.deadline - now);
|
||||
const count = Number.isFinite(remaining) ? String(Math.ceil(remaining / 1000)) : '';
|
||||
const countdown = el('.countdown-number');
|
||||
if (s.phase === 'countdown' && countdown.textContent !== count) { countdown.textContent = count; countdown.dataset.count = count; }
|
||||
el('.duel-cue').hidden = revealing || resultVisible;
|
||||
el('.selection-status').textContent = !resultVisible && !s.opponentPresent ? '연결 복구 중' : s.phase === 'countdown' ? Number.isFinite(remaining) ? '' : '상대 연결 중' : revealing ? '동시 공개' : resultVisible ? '' : busy ? '선택 중' : s.ownAction ? '선택 완료' : remaining > 0 ? '행동 선택' : '결과 대기';
|
||||
el('.time-label').textContent = s.phase === 'selecting' && !revealing ? `${(remaining / 1000).toFixed(1)}초` : '';
|
||||
const progress = s.phase === 'countdown' ? remaining / MATCH_COUNTDOWN_MS : s.phase === 'selecting' ? remaining / TURN_DURATION_MS : 0;
|
||||
el('.time-track').style.setProperty('--time', String(Math.min(1, progress)));
|
||||
el('.time-track').setAttribute('aria-valuenow', String(Math.round(Math.min(1, progress) * 100)));
|
||||
el('.time-track').setAttribute('aria-label', s.phase === 'countdown' ? '대전 시작까지 남은 시간' : '남은 선택 시간');
|
||||
el('.timing').dataset.urgent = String(s.phase === 'selecting' && remaining <= 1000 && !s.ownAction);
|
||||
buttons.forEach(button => {
|
||||
const id = button.dataset.action as ActionId;
|
||||
const affordable = s.energy[s.selfIndex] >= ACTIONS[id].cost;
|
||||
button.disabled = busy || !s.opponentPresent || s.phase !== 'selecting' || !!s.ownAction || now < s.startsAt || now >= s.deadline || !affordable;
|
||||
button.dataset.unavailable = String(!affordable);
|
||||
button.classList.toggle('chosen', s.ownAction === id); button.setAttribute('aria-pressed', String(s.ownAction === id));
|
||||
button.title = !affordable ? `기 ${ACTIONS[id].cost} 필요 · ${ACTIONS[id].effect}` : ACTIONS[id].effect;
|
||||
button.querySelector('small')!.textContent = !affordable ? `기 ${ACTIONS[id].cost} 필요` : id === 'charge' || id === 'guard' ? ACTIONS[id].effect : `기 ${ACTIONS[id].cost} 소모`;
|
||||
});
|
||||
container.querySelectorAll<HTMLElement>('[data-fighter]').forEach((node, i) => {
|
||||
const self = i === s.selfIndex;
|
||||
node.dataset.locked = String(self && !!s.ownAction && phase === 'selecting');
|
||||
if (resultVisible && !s.aborted && s.finished?.reason === 'hit' && s.reveal?.matchId === s.matchId
|
||||
&& s.reveal.hitIndex === i && s.reveal.actions[1 - i] === 'beam') node.dataset.ash = 'true';
|
||||
else delete node.dataset.ash;
|
||||
node.querySelector('.choice-status')!.textContent = phase === 'selecting' ? self && s.ownAction ? `${ACTIONS[s.ownAction].name} · 선택 완료` : !self && !s.opponentPresent ? '연결 끊김' : '' : '';
|
||||
if (resultVisible && s.finished && !s.aborted && !finishing) node.dataset.reaction = s.finished.winnerIndex === null ? 'draw' : s.finished.winnerIndex === i ? 'win' : 'loss';
|
||||
else delete node.dataset.reaction;
|
||||
});
|
||||
if (resultVisible && s.finished) {
|
||||
if (resultReported !== s.matchId) { resultReported = s.matchId; onResult(); }
|
||||
const f = s.finished;
|
||||
const outcome = s.aborted ? 'aborted' : f.winnerIndex === null ? 'draw' : f.winnerIndex === s.selfIndex ? 'win' : 'loss';
|
||||
container.dataset.outcome = outcome;
|
||||
arena.dataset.result = outcome;
|
||||
el('.result-banner h2').textContent = ({ win: '승리!', loss: '패배', draw: '무승부', aborted: '대전 중단' })[outcome];
|
||||
el('.match-result').dataset.result = outcome;
|
||||
el('.finish-reason').textContent = ({ hit: '명중', timeout: '선택 시간 초과', double_timeout: '양쪽 선택 시간 초과', forfeit: '기권', disconnect: '연결 종료', double_disconnect: '양쪽 연결 종료', connection_failed: '연결을 복구하지 못했습니다.', protocol_error: '대전 상태를 맞추지 못했습니다.' })[f.reason];
|
||||
const stats = f.stats[s.mode];
|
||||
el('.result-stats').textContent = f.saved ? `현재 연승 ${stats.current} · 최고 연승 ${stats.best}` : '';
|
||||
el('.save-status').textContent = s.saveStatus === 'failed' ? '기록을 저장하지 못했습니다. 다시 시도하세요.' : f.saved ? '' : '기록 저장 중';
|
||||
el<HTMLButtonElement>('.rematch').disabled = !f.saved || (!s.ranked && (!!s.rematch[s.selfIndex] || s.opponentLeft));
|
||||
el('.rematch-status').textContent = s.ranked ? '' : s.opponentLeft ? '상대가 나갔습니다.' : s.rematch[s.selfIndex] ? '상대의 재대전 동의 대기' : s.rematch[s.selfIndex === 0 ? 1 : 0] ? '상대가 재대전을 요청했습니다.' : '';
|
||||
} else {
|
||||
delete arena.dataset.result;
|
||||
if (arena.dataset.outcome) container.dataset.outcome = arena.dataset.outcome;
|
||||
else delete container.dataset.outcome;
|
||||
}
|
||||
}
|
||||
const interval = setInterval(tick, 100);
|
||||
return {
|
||||
update(state: LocalMatchState) {
|
||||
if (current?.matchId === state.matchId && current.version > state.version) return;
|
||||
if (lastId !== state.matchId) { cutscene.reset(); renderer.reset(); lastId = state.matchId; busy = false; submission++; }
|
||||
if (current && current.turnId !== state.turnId) { busy = false; submission++; }
|
||||
current = state; offset = state.clockNow - Date.now();
|
||||
container.dataset.self = String(state.selfIndex);
|
||||
el('.energy-count').textContent = String(state.energy[state.selfIndex]);
|
||||
el('.energy').setAttribute('aria-label', `내 기 ${state.energy[state.selfIndex]}`);
|
||||
container.querySelectorAll<HTMLElement>('[data-fighter]').forEach((node, i) => {
|
||||
const appearance = (state.appearances ?? appearances)?.[i];
|
||||
const signature = appearanceKey(appearance);
|
||||
if (appearanceSignatures[i] !== signature) {
|
||||
node.querySelector('.character-direction')!.innerHTML = fighterArt(i, false, appearance);
|
||||
appearanceSignatures[i] = signature;
|
||||
}
|
||||
node.classList.toggle('self', i === state.selfIndex);
|
||||
node.setAttribute('role', 'group');
|
||||
node.setAttribute('aria-label', i === state.selfIndex ? '내 캐릭터' : state.mode === 'bot' ? '봇 캐릭터' : '상대 캐릭터');
|
||||
});
|
||||
if (state.reveal && !state.aborted && (!state.finished || state.finished.reason === 'hit')) renderer.play(state.reveal, Date.now() + offset);
|
||||
else renderer.reset();
|
||||
tick();
|
||||
cutscene.update(state);
|
||||
}, destroy() { submission++; clearInterval(interval); cutscene.destroy(); renderer.destroy(); container.innerHTML = ''; current = undefined; },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
/* Match layout only; character poses, projectiles and finish cutscenes stay in game.css. */
|
||||
body:is([data-screen=match], [data-screen=result]) {
|
||||
overflow: hidden;
|
||||
background: #d9ddd4;
|
||||
}
|
||||
body:is([data-screen=match], [data-screen=result]) .app {
|
||||
width: 100%; max-width: none; height: 100dvh; min-height: 0; padding: 0;
|
||||
}
|
||||
body:is([data-screen=match], [data-screen=result]) .app > :is(header, footer) { display: none; }
|
||||
body:is([data-screen=match], [data-screen=result]) main { padding: 0; min-height: 0; }
|
||||
#match {
|
||||
--stage-top: calc(120px + env(safe-area-inset-top));
|
||||
--stage-bottom: 24px;
|
||||
--fighter-size: min(32vw, calc((100cqh - 38px) * .866667), 460px);
|
||||
--edge: clamp(20px, 3vw, 56px);
|
||||
--self-position: 26.63%;
|
||||
position: relative; isolation: isolate; flex: 1; width: 100%; max-width: none;
|
||||
min-height: 0; height: 100%; margin: 0;
|
||||
display: grid; grid-template-rows: minmax(0, 1fr) auto; overflow: hidden;
|
||||
background: #bebda9;
|
||||
}
|
||||
#match[data-self="1"] { --self-position: 73.37%; }
|
||||
#match[data-phase=finished] { --stage-top: calc(260px + env(safe-area-inset-top)); }
|
||||
#match .arena, #match[data-phase=finished] .arena {
|
||||
--floor-line: calc(100% - var(--stage-bottom) - 80px);
|
||||
position: relative; grid-area: 1 / 1; container-type: size;
|
||||
min-height: 0; border: 0; border-radius: 0; overflow: visible;
|
||||
padding: var(--stage-top) 9vw var(--stage-bottom);
|
||||
align-items: flex-end;
|
||||
background: linear-gradient(180deg, #d6dfd9 0%, #e9e6d9 var(--floor-line), #d0cbb7 var(--floor-line), #bebda9 100%);
|
||||
}
|
||||
#match .arena::before {
|
||||
inset: 0; border: 0; transform: none;
|
||||
background: radial-gradient(ellipse at 50% 38%, #fff9e966, transparent 65%);
|
||||
}
|
||||
#match .arena::after {
|
||||
inset: var(--floor-line) -25% -100dvh; width: 150%; height: auto; border: 0; border-radius: 0;
|
||||
background: repeating-linear-gradient(174deg, transparent 0 55px, #60695512 56px 57px, transparent 58px 100px);
|
||||
}
|
||||
#match .arena-floor {
|
||||
inset: var(--floor-line) 0 0; width: 100%; height: auto; border-radius: 0;
|
||||
background: transparent; border-top: 2px solid #69756838;
|
||||
}
|
||||
/* Fighter slots stay fixed so effects and the controls share the same coordinates. */
|
||||
#match .fighter { position: static; width: 43%; }
|
||||
#match .avatar, #match[data-phase=finished] .avatar {
|
||||
width: var(--fighter-size);
|
||||
max-width: none; margin: 0; left: 50%; translate: -50% 0;
|
||||
}
|
||||
#match .fighter-caption { height: 30px; margin: 8px 0 0; }
|
||||
#match .energy { min-width: 0; border: 0; padding: 0 0 8px; background: transparent; gap: 6px; justify-content: center; }
|
||||
#match .energy-count { font-size: 24px; font-weight: 900; }
|
||||
#match .energy > svg { width: 22px; height: 22px; color: #8c633e; }
|
||||
#match .energy-change { position: static; margin-left: 6px; font-size: 18px; }
|
||||
#match .match-top { position: absolute; inset: 0; z-index: 15; margin: 0; pointer-events: none; }
|
||||
#match .match-title {
|
||||
position: absolute; top: calc(24px + env(safe-area-inset-top)); left: 50%;
|
||||
transform: translateX(-50%); gap: 10px; white-space: nowrap;
|
||||
}
|
||||
#match .mode-label { background: transparent; color: #59665d; padding: 0; font-size: 11px; font-weight: 600; }
|
||||
#match .turn-heading { font-size: 17px; letter-spacing: -.5px; }
|
||||
#match .timing {
|
||||
position: absolute; top: calc(54px + env(safe-area-inset-top)); left: 50%;
|
||||
transform: translateX(-50%); width: 220px; min-height: 37px; padding: 0 0 13px;
|
||||
z-index: 12; font-size: 11px; align-items: center; gap: 8px;
|
||||
}
|
||||
#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; }
|
||||
#match[data-locked=true] .time-track span { background: #557b85; }
|
||||
#match .timing[data-urgent=true] .time-track span { background: #c04128; }
|
||||
#match .match-menu {
|
||||
pointer-events: auto;
|
||||
position: absolute; top: calc(25px + env(safe-area-inset-top));
|
||||
right: max(var(--edge), env(safe-area-inset-right));
|
||||
}
|
||||
#match .match-menu summary {
|
||||
display: grid; place-items: center; list-style: none; cursor: pointer;
|
||||
min-width: 52px; min-height: 44px; padding: 8px 12px;
|
||||
border: 1px solid #52615655; border-radius: 3px; background: #f4f0e3;
|
||||
font-size: 12px; font-weight: 700;
|
||||
}
|
||||
#match .match-menu summary::-webkit-details-marker { display: none; }
|
||||
#match .match-menu summary:focus-visible { outline: 3px solid #266acc; outline-offset: 4px; }
|
||||
#match .match-menu[open] summary { background: #303c38; color: #fff9e9; }
|
||||
#match .match-menu-items {
|
||||
position: absolute; top: calc(100% + 8px); right: 0; width: 132px;
|
||||
padding: 6px; background: #f4f0e3; border: 1px solid #697568;
|
||||
box-shadow: 3px 4px 0 #303c3820;
|
||||
}
|
||||
#match .match-menu-items button {
|
||||
display: block; width: 100%; min-height: 44px; border: 0; border-radius: 0;
|
||||
color: #303c38; text-align: left;
|
||||
}
|
||||
#match .duel-cue, #match[data-phase=countdown] .duel-cue { top: 43%; }
|
||||
#match:not([data-phase=countdown]) .versus { display: none; }
|
||||
#match[data-phase=countdown] .countdown-number { border-radius: 3px; }
|
||||
#match .clash-label { top: 30%; }
|
||||
#match .player-controls {
|
||||
position: relative; grid-area: 2 / 1; justify-self: start; z-index: 12;
|
||||
left: var(--self-position); translate: -50% 0;
|
||||
width: min(640px, calc(50% - 24px));
|
||||
margin-bottom: max(20px, env(safe-area-inset-bottom));
|
||||
}
|
||||
#match .action-buttons {
|
||||
position: relative; width: 100%;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 8px;
|
||||
margin: 0; padding: 8px;
|
||||
border: 1px solid #3f493e; border-radius: 4px; background: #303a33;
|
||||
box-shadow: 0 4px 0 #202a2580;
|
||||
}
|
||||
#match .action-buttons button {
|
||||
display: flex; flex-direction: column; grid-column: auto; gap: 5px;
|
||||
min-height: 86px; padding: 10px 4px; text-align: center;
|
||||
border: 1px solid #dfdac8; border-bottom: 3px solid #b0ab95; border-radius: 2px;
|
||||
background: #f5f0de; color: #303a33; box-shadow: none;
|
||||
}
|
||||
#match .action-buttons .action-symbol {
|
||||
grid-row: span 2; align-self: center; width: 28px; height: 28px;
|
||||
}
|
||||
#match .action-buttons strong { font-size: 13px; white-space: nowrap; }
|
||||
#match .action-buttons small { color: #58604f; font-size: 10px; }
|
||||
#match .action-buttons button:hover:not(:disabled) { background: #fff9e9; border-color: var(--action-color); transform: translateY(-2px); }
|
||||
#match .action-buttons button:active:not(:disabled) { transform: translateY(1px); }
|
||||
#match .action-buttons button:disabled { background: #b9bdb0; border-color: #a1a99b; color: #485146; }
|
||||
#match .action-buttons button[data-unavailable=true] { background: #555e53; border-color: #737d6c; color: #dde0d5; }
|
||||
#match .action-buttons button[data-unavailable=true] small { color: #dde0d5; }
|
||||
#match .action-buttons button.chosen { background: #edd5a5; border-color: #c39244; color: #453a26; box-shadow: inset 0 0 0 1px #c39244; }
|
||||
#match .chosen-mark { width: 20px; height: 20px; top: -6px; right: -4px; padding: 3px; border-width: 1px; }
|
||||
#match .match-result {
|
||||
position: relative; grid-area: 2 / 1; justify-self: center; z-index: 12;
|
||||
width: min(480px, calc(100% - 32px));
|
||||
max-height: calc(100dvh - 150px - env(safe-area-inset-top) - env(safe-area-inset-bottom));
|
||||
overflow-y: auto; margin: 0 0 max(20px, env(safe-area-inset-bottom)); padding: 8px 12px;
|
||||
border: 0; border-radius: 0; box-shadow: none; background: transparent;
|
||||
}
|
||||
#match .result-stats { margin: 0; color: #414c3c; }
|
||||
#match .match-result .split { margin-top: 12px; }
|
||||
#match .match-result button { border-radius: 3px; }
|
||||
#match .result-banner {
|
||||
position: absolute; z-index: 10; top: calc(90px + env(safe-area-inset-top));
|
||||
left: 50%; translate: -50% 0; width: calc(100% - 32px); text-align: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
#match .result-banner h2 {
|
||||
margin: 0; font-size: clamp(80px, 11vw, 164px); font-weight: 950; font-style: italic;
|
||||
line-height: 1; letter-spacing: -.065em; color: #ffd359;
|
||||
-webkit-text-stroke: 2px #755120; paint-order: stroke fill;
|
||||
text-shadow: 0 5px 0 #72501c, 0 10px 20px #543d2133;
|
||||
}
|
||||
#match .result-banner .finish-reason { margin: 14px 0 0; font-size: 13px; color: #4d5547; }
|
||||
#match[data-outcome=loss] .result-banner h2 { color: #d78063; -webkit-text-stroke-color: #703d32; text-shadow: 0 5px 0 #703d32; }
|
||||
#match:is([data-outcome=draw], [data-outcome=aborted]) .result-banner h2 { color: #e9eadc; -webkit-text-stroke-color: #50635d; text-shadow: 0 5px 0 #50635d; }
|
||||
body:is([data-screen=match], [data-screen=result]) #notice:not(:empty) {
|
||||
position: fixed; z-index: 18; top: calc(108px + env(safe-area-inset-top));
|
||||
left: 50%; transform: translateX(-50%); width: max-content; max-width: calc(100% - 32px);
|
||||
margin: 0; padding: 10px 14px; background: #fff6e4; border: 1px solid #a85039;
|
||||
}
|
||||
body:is([data-screen=match], [data-screen=result]) #reconnect {
|
||||
position: fixed; z-index: 18; top: calc(158px + env(safe-area-inset-top)); right: 16px;
|
||||
min-height: 44px; margin: 0; border-radius: 3px;
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
#match {
|
||||
--edge: 16px; --stage-bottom: 16px;
|
||||
--self-position: 25.09%;
|
||||
--stage-top: calc(100px + env(safe-area-inset-top));
|
||||
--fighter-size: min(60vw, calc((100cqh - 38px) * .866667), 360px);
|
||||
}
|
||||
#match[data-self="1"] { --self-position: 74.91%; }
|
||||
#match[data-phase=finished] { --stage-top: calc(210px + env(safe-area-inset-top)); }
|
||||
#match .arena { padding-inline: 3vw; }
|
||||
#match .fighter { width: 47%; }
|
||||
#match .match-title { top: calc(24px + env(safe-area-inset-top)); left: var(--edge); transform: none; }
|
||||
#match .turn-heading { font-size: 16px; }
|
||||
#match .match-menu { top: calc(12px + env(safe-area-inset-top)); }
|
||||
#match .energy { gap: 3px; }
|
||||
#match .energy-count { font-size: 25px; }
|
||||
#match .energy > svg { width: 18px; height: 18px; }
|
||||
#match .timing { top: calc(65px + env(safe-area-inset-top)); width: 160px; min-height: 30px; flex-direction: row; gap: 3px; padding-bottom: 9px; }
|
||||
#match .selection-status { font-size: 10px; }
|
||||
#match .time-label { font-size: 14px; }
|
||||
#match .player-controls { width: calc(50% - 16px); margin-bottom: max(12px, env(safe-area-inset-bottom)); }
|
||||
#match .action-buttons { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 5px; padding: 6px; }
|
||||
#match .action-buttons button {
|
||||
grid-column: auto; display: flex; flex-direction: column; gap: 4px;
|
||||
min-height: 56px; padding: 8px 2px; text-align: center;
|
||||
}
|
||||
#match .action-buttons .action-symbol { display: none; }
|
||||
#match .action-buttons button:nth-child(n+4) {
|
||||
grid-column: auto; display: flex; flex-direction: column;
|
||||
min-height: 56px; text-align: center; padding: 8px 2px;
|
||||
}
|
||||
#match .action-buttons button:last-child { grid-column: 1 / -1; }
|
||||
#match .action-buttons strong { font-size: 11px; }
|
||||
#match .action-buttons small { font-size: 9px; white-space: normal; }
|
||||
#match .fighter-caption { font-size: 10px; }
|
||||
#match .clash-label { top: 32%; font-size: 18px; }
|
||||
#match .match-result { margin-bottom: max(12px, env(safe-area-inset-bottom)); padding: 8px; }
|
||||
#match .result-banner { top: calc(82px + env(safe-area-inset-top)); }
|
||||
#match .result-banner h2 { font-size: clamp(60px, 18vw, 100px); }
|
||||
#match .match-result .split button { min-height: 44px; font-size: 13px; }
|
||||
}
|
||||
@media (max-height: 500px) and (min-width: 521px) {
|
||||
#match {
|
||||
--edge: max(16px, env(safe-area-inset-left)); --stage-bottom: 8px;
|
||||
--stage-top: calc(78px + env(safe-area-inset-top));
|
||||
--self-position: 28.34%;
|
||||
--fighter-size: min(26vw, calc((100cqh - 38px) * .866667), 260px);
|
||||
}
|
||||
#match[data-self="1"] { --self-position: 71.66%; }
|
||||
#match[data-phase=finished] { --stage-top: calc(96px + env(safe-area-inset-top)); }
|
||||
#match .arena { padding-inline: 12vw; }
|
||||
#match .match-title { top: calc(12px + env(safe-area-inset-top)); left: 50%; transform: translateX(-50%); }
|
||||
#match .match-menu { top: calc(10px + env(safe-area-inset-top)); right: max(12px, env(safe-area-inset-right)); }
|
||||
#match .timing { top: calc(38px + env(safe-area-inset-top)); width: 120px; min-height: 33px; flex-direction: row; padding-bottom: 8px; }
|
||||
#match .player-controls { width: calc(50% - 24px); margin-bottom: max(8px, env(safe-area-inset-bottom)); }
|
||||
#match .energy { padding-bottom: 4px; }
|
||||
#match .energy-count { font-size: 18px; }
|
||||
#match .action-buttons { grid-template-columns: repeat(5, minmax(0, 1fr)); padding: 5px; gap: 4px; }
|
||||
#match .action-buttons button, #match .action-buttons button:nth-child(n+4) {
|
||||
display: flex; grid-column: auto; flex-direction: column; min-height: 56px; padding: 7px 2px; gap: 4px; text-align: center;
|
||||
}
|
||||
#match .action-buttons .action-symbol, #match .action-buttons button:nth-child(n+4) .action-symbol { display: none; }
|
||||
#match .action-buttons strong { font-size: 10px; white-space: normal; overflow-wrap: anywhere; }
|
||||
#match .action-buttons small { font-size: 9px; white-space: normal; }
|
||||
#match .duel-cue, #match[data-phase=countdown] .duel-cue { top: 44%; }
|
||||
#match .clash-label { top: 25%; font-size: 17px; }
|
||||
#match .match-result {
|
||||
display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: center; gap: 4px 12px;
|
||||
width: min(650px, calc(100% - 32px)); margin-bottom: max(8px, env(safe-area-inset-bottom));
|
||||
max-height: calc(100dvh - 160px - env(safe-area-inset-top)); padding: 6px 10px;
|
||||
}
|
||||
#match .result-stats { margin: 0; font-size: 11px; }
|
||||
#match .match-result .split { grid-area: 1 / 2; margin: 0; gap: 8px; }
|
||||
#match .match-result .split button { min-height: 44px; padding: 8px; font-size: 12px; }
|
||||
#match :is(.save-status, .rematch-status) { grid-column: 1 / -1; margin: 0; font-size: 10px; line-height: 1.3; }
|
||||
#match .result-banner { top: calc(56px + env(safe-area-inset-top)); }
|
||||
#match .result-banner h2 { font-size: 48px; }
|
||||
#match .result-banner .finish-reason { margin-top: 8px; font-size: 11px; }
|
||||
}
|
||||
@media (max-height: 500px) and (min-width: 521px) and (max-width: 760px) {
|
||||
#match { --self-position: 29.86%; }
|
||||
#match[data-self="1"] { --self-position: 70.14%; }
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import type { Socket } from 'socket.io-client';
|
||||
import { RULES_VERSION } from '../../constants/gameplay.js';
|
||||
import { CONNECT_TIMEOUT_MS, MAX_PEER_MESSAGE_BYTES, PROTOCOL_VERSION } from '../../constants/webrtc.js';
|
||||
import type { PeerMessage, SignalingPacket } from '../shared/p2p-types.js';
|
||||
|
||||
export type PeerState = 'connecting' | 'connected' | 'disconnected' | 'failed' | 'closed';
|
||||
export interface PeerConfig {
|
||||
matchId: string; roomId: string; slot: 0 | 1; epoch?: number; iceServers: RTCIceServer[];
|
||||
createSignaling(): Socket; createConnection?: (config: RTCConfiguration) => RTCPeerConnection;
|
||||
refreshIce?: () => Promise<RTCIceServer[]>;
|
||||
}
|
||||
export function createPeer(config: PeerConfig) {
|
||||
let pc: RTCPeerConnection | undefined, channel: RTCDataChannel | undefined, signal: Socket | undefined;
|
||||
let epoch = config.epoch ?? 0, disposed = false, ready = false, remoteReady = false, offering = false;
|
||||
let connectionPromise: Promise<void> | undefined, yes: (() => void) | undefined, no: ((e: Error) => void) | undefined;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined, signalQueue = Promise.resolve();
|
||||
let candidates: RTCIceCandidateInit[] = [];
|
||||
let generation = 0;
|
||||
const messages = new Set<(message: PeerMessage) => void>(), states = new Set<(state: PeerState) => void>();
|
||||
const emitState = (state: PeerState) => { for (const handler of states) handler(state); };
|
||||
const stopSignaling = () => { signal?.removeAllListeners(); signal?.disconnect(); signal = undefined; };
|
||||
function discardConnection() {
|
||||
const oldChannel = channel, oldPc = pc; channel = undefined; pc = undefined;
|
||||
if (oldChannel) { oldChannel.onopen = oldChannel.onclose = oldChannel.onerror = oldChannel.onmessage = null; oldChannel.close(); }
|
||||
if (oldPc) { oldPc.ondatachannel = oldPc.onicecandidate = oldPc.onconnectionstatechange = null; oldPc.close(); }
|
||||
}
|
||||
function failure(error: unknown, attempt = generation) {
|
||||
// In-flight ICE acknowledgements can arrive after both sides close signaling.
|
||||
if (disposed || attempt !== generation || (ready && channel?.readyState === 'open')) return;
|
||||
clearTimeout(timer); stopSignaling(); no?.(error instanceof Error ? error : new Error('PEER_FAILED'));
|
||||
yes = undefined; no = undefined; connectionPromise = undefined; ready = false; emitState('failed');
|
||||
}
|
||||
function complete() {
|
||||
if (!remoteReady || channel?.readyState !== 'open' || ready) return;
|
||||
ready = true; clearTimeout(timer); stopSignaling();
|
||||
yes?.(); yes = undefined; no = undefined; emitState('connected');
|
||||
}
|
||||
function announceReady() {
|
||||
if (channel?.readyState === 'open') {
|
||||
channel.send(JSON.stringify({ type: 'transport:ready', matchId: config.matchId, epoch, slot: config.slot, protocolVersion: PROTOCOL_VERSION, rulesVersion: RULES_VERSION }));
|
||||
complete();
|
||||
}
|
||||
}
|
||||
function installChannel(next: RTCDataChannel) {
|
||||
channel = next;
|
||||
channel.onopen = announceReady;
|
||||
channel.onclose = () => { if (!disposed) { ready = false; emitState('disconnected'); } };
|
||||
channel.onerror = () => { if (!disposed) emitState('disconnected'); };
|
||||
channel.onmessage = event => {
|
||||
if (disposed || typeof event.data !== 'string' || new TextEncoder().encode(event.data).byteLength > MAX_PEER_MESSAGE_BYTES) return;
|
||||
let message: any;
|
||||
try { message = JSON.parse(event.data); } catch { return; }
|
||||
if (!message || message.matchId !== config.matchId || message.protocolVersion !== PROTOCOL_VERSION || message.rulesVersion !== RULES_VERSION) return;
|
||||
if (message.type === 'transport:ready') {
|
||||
if (message.epoch !== epoch || message.slot === config.slot) return;
|
||||
remoteReady = true; complete(); return;
|
||||
}
|
||||
if (!ready || message.connectionEpoch !== epoch) return;
|
||||
for (const handler of messages) handler(message as PeerMessage);
|
||||
};
|
||||
if (channel.readyState === 'open') announceReady();
|
||||
}
|
||||
async function sendSignal(type: SignalingPacket['type'], payload: SignalingPacket['payload'], attempt = generation) {
|
||||
if (disposed || attempt !== generation) return;
|
||||
if (!signal?.connected) throw new Error('SIGNAL_DISCONNECTED');
|
||||
const ack = await signal.timeout(5000).emitWithAck('signal', { type, payload, matchId: config.matchId, epoch } satisfies SignalingPacket);
|
||||
if (disposed || attempt !== generation) return;
|
||||
if (!ack?.ok) throw new Error(ack?.code ?? 'SIGNAL_FAILED');
|
||||
}
|
||||
function installConnection() {
|
||||
const current = pc = (config.createConnection ?? (settings => new RTCPeerConnection(settings)))({ iceServers: config.iceServers });
|
||||
current.ondatachannel = event => installChannel(event.channel);
|
||||
current.onicecandidate = event => {
|
||||
const attempt = generation;
|
||||
if (current === pc && event.candidate && signal?.connected) void sendSignal('ice', event.candidate.toJSON(), attempt).catch(error => failure(error, attempt));
|
||||
};
|
||||
current.onconnectionstatechange = () => {
|
||||
if (disposed || current !== pc) return;
|
||||
if (current.connectionState === 'failed' || current.connectionState === 'disconnected') { ready = false; emitState('disconnected'); }
|
||||
else if (current.connectionState === 'connected' && !ready) announceReady();
|
||||
};
|
||||
if (config.slot === 0) installChannel(current.createDataChannel('game', { ordered: true }));
|
||||
}
|
||||
async function received(packet: SignalingPacket, attempt: number) {
|
||||
if (disposed || attempt !== generation || !pc || packet.matchId !== config.matchId || packet.epoch !== epoch) return;
|
||||
const current = pc;
|
||||
if (packet.type === 'ice') {
|
||||
if (!current.remoteDescription) candidates.push(packet.payload as RTCIceCandidateInit);
|
||||
else await current.addIceCandidate(packet.payload as RTCIceCandidateInit);
|
||||
return;
|
||||
}
|
||||
if ((packet.type === 'offer' && config.slot !== 1) || (packet.type === 'answer' && config.slot !== 0)) return;
|
||||
await current.setRemoteDescription(packet.payload as RTCSessionDescriptionInit);
|
||||
if (disposed || attempt !== generation) return;
|
||||
for (const candidate of candidates.splice(0)) await current.addIceCandidate(candidate);
|
||||
if (packet.type === 'offer') {
|
||||
const answer = await current.createAnswer(); if (disposed || attempt !== generation) return;
|
||||
await current.setLocalDescription(answer);
|
||||
await sendSignal('answer', current.localDescription!, attempt);
|
||||
}
|
||||
if (attempt === generation && channel?.readyState === 'open') announceReady();
|
||||
}
|
||||
function connect(): Promise<void> {
|
||||
if (disposed) return Promise.reject(new Error('PEER_CLOSED'));
|
||||
if (ready) return Promise.resolve();
|
||||
if (connectionPromise) return connectionPromise;
|
||||
const attempt = ++generation;
|
||||
remoteReady = false; offering = false;
|
||||
connectionPromise = new Promise<void>((resolve, reject) => { yes = resolve; no = reject; });
|
||||
const currentPromise = connectionPromise;
|
||||
timer = setTimeout(() => failure(new Error('PEER_TIMEOUT'), attempt), CONNECT_TIMEOUT_MS);
|
||||
if (!pc || pc.connectionState === 'closed' || channel?.readyState === 'closed') { discardConnection(); installConnection(); }
|
||||
signal = config.createSignaling();
|
||||
signalQueue = Promise.resolve();
|
||||
signal.on('signal', (packet: SignalingPacket) => { signalQueue = signalQueue.then(() => received(packet, attempt)).catch(error => failure(error, attempt)); });
|
||||
signal.on('signal:peers', ({ count }: { count: number }) => {
|
||||
if (attempt !== generation || count < 2 || config.slot !== 0 || offering || !pc) return;
|
||||
offering = true;
|
||||
const current = pc;
|
||||
void (async () => {
|
||||
const offer = await current.createOffer(); if (disposed || attempt !== generation) return;
|
||||
await current.setLocalDescription(offer); await sendSignal('offer', current.localDescription!, attempt);
|
||||
})().catch(error => failure(error, attempt));
|
||||
});
|
||||
signal.on('connect_error', error => failure(error, attempt));
|
||||
signal.on('disconnect', reason => { if (!ready && reason === 'io server disconnect') failure(new Error('SIGNAL_CLOSED'), attempt); });
|
||||
signal.connect(); emitState('connecting'); return currentPromise;
|
||||
}
|
||||
return {
|
||||
connect,
|
||||
get epoch() { return epoch; },
|
||||
get connected() { return ready && channel?.readyState === 'open'; },
|
||||
send(message: PeerMessage) {
|
||||
if (!ready || channel?.readyState !== 'open') throw new Error('PEER_NOT_CONNECTED');
|
||||
const body = JSON.stringify(message);
|
||||
if (new TextEncoder().encode(body).byteLength > MAX_PEER_MESSAGE_BYTES) throw new Error('MESSAGE_TOO_LARGE');
|
||||
channel.send(body);
|
||||
},
|
||||
onMessage(handler: (message: PeerMessage) => void) { messages.add(handler); return () => { messages.delete(handler); }; },
|
||||
onState(handler: (state: PeerState) => void) { states.add(handler); return () => { states.delete(handler); }; },
|
||||
async restart(nextEpoch = epoch + 1) {
|
||||
generation++;
|
||||
stopSignaling(); clearTimeout(timer); no?.(new Error('PEER_RESTARTED'));
|
||||
ready = false; remoteReady = false; yes = undefined; no = undefined; connectionPromise = undefined;
|
||||
epoch = nextEpoch; candidates = [];
|
||||
if (channel?.readyState === 'closed' || channel?.readyState === 'closing' || pc?.connectionState === 'closed') discardConnection();
|
||||
if (config.refreshIce) {
|
||||
config.iceServers = await config.refreshIce();
|
||||
if (disposed) throw new Error('PEER_CLOSED');
|
||||
if (pc) {
|
||||
try { pc.setConfiguration({ ...pc.getConfiguration(), iceServers: config.iceServers }); }
|
||||
catch { discardConnection(); }
|
||||
}
|
||||
}
|
||||
pc?.restartIce(); return connect();
|
||||
},
|
||||
stats: async () => pc?.getStats(),
|
||||
close() {
|
||||
disposed = true; generation++; ready = false; clearTimeout(timer); stopSignaling();
|
||||
no?.(new Error('PEER_CLOSED')); no = undefined; yes = undefined;
|
||||
channel?.close(); pc?.close(); emitState('closed'); messages.clear(); states.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
export type PeerConnection = ReturnType<typeof createPeer>;
|
||||
@@ -0,0 +1,93 @@
|
||||
Copyright 2015 The Black Han Sans Project Authors (https://github.com/zesstype/Black-Han-Sans)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://openfontlicense.org
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -0,0 +1,56 @@
|
||||
import { PENDING_RESULT_KEY, RESULT_RETRY_DELAYS_MS } from '../../constants/recording.js';
|
||||
import type { RecordAck, ResultSubmission } from '../shared/p2p-types.js';
|
||||
|
||||
export type SessionStorage = Pick<Storage, 'getItem' | 'setItem' | 'removeItem'>;
|
||||
interface ReporterApi { gameSessionId: string; record(data: ResultSubmission): Promise<RecordAck> }
|
||||
interface RetryClock { setTimeout(fn: () => void, ms: number): any; clearTimeout(id: any): void }
|
||||
const clock: RetryClock = { setTimeout: (fn, ms) => setTimeout(fn, ms), clearTimeout: id => clearTimeout(id) };
|
||||
export function createResultReporter(api: ReporterApi, storage: SessionStorage, timers: RetryClock = clock) {
|
||||
let pending: ResultSubmission | undefined, promise: Promise<RecordAck> | undefined;
|
||||
let resolve: ((ack: RecordAck) => void) | undefined, reject: ((error: Error) => void) | undefined;
|
||||
let timer: any, attempts = 0, running = false, destroyed = false;
|
||||
async function attempt() {
|
||||
if (!pending || running || destroyed || !resolve) return;
|
||||
timers.clearTimeout(timer); timer = undefined; running = true;
|
||||
try {
|
||||
const response = await api.record(structuredClone(pending));
|
||||
if (destroyed) return;
|
||||
storage.removeItem(PENDING_RESULT_KEY); pending = undefined;
|
||||
resolve(response); resolve = undefined; reject = undefined; promise = undefined;
|
||||
} catch (error) {
|
||||
if (destroyed) return;
|
||||
const status = (error as { status?: number }).status;
|
||||
if (status && status >= 400 && status < 500 && status !== 408 && status !== 429) {
|
||||
reject?.(error as Error); resolve = undefined; reject = undefined; promise = undefined;
|
||||
} else {
|
||||
const delay = RESULT_RETRY_DELAYS_MS[Math.min(attempts++, RESULT_RETRY_DELAYS_MS.length - 1)];
|
||||
timer = timers.setTimeout(() => { void attempt(); }, delay);
|
||||
}
|
||||
} finally { running = false; }
|
||||
}
|
||||
const online = () => { void attempt(); };
|
||||
globalThis.addEventListener?.('online', online);
|
||||
function submit(result: ResultSubmission): Promise<RecordAck> {
|
||||
if (destroyed) return Promise.reject(new Error('REPORTER_CLOSED'));
|
||||
if (pending && JSON.stringify(pending) !== JSON.stringify(result)) return Promise.reject(new Error('RESULT_PENDING'));
|
||||
if (promise) return promise;
|
||||
pending = structuredClone(result); attempts = 0;
|
||||
storage.setItem(PENDING_RESULT_KEY, JSON.stringify({ gameSessionId: api.gameSessionId, result: pending }));
|
||||
promise = new Promise<RecordAck>((yes, no) => { resolve = yes; reject = no; });
|
||||
const current = promise; void attempt(); return current;
|
||||
}
|
||||
return {
|
||||
submit,
|
||||
async resumePending(): Promise<RecordAck | undefined> {
|
||||
const raw = storage.getItem(PENDING_RESULT_KEY); if (!raw) return;
|
||||
const saved = JSON.parse(raw) as { gameSessionId: string; result: ResultSubmission };
|
||||
if (saved.gameSessionId !== api.gameSessionId) throw new Error('SESSION_MISMATCH');
|
||||
return submit(saved.result);
|
||||
},
|
||||
destroy() {
|
||||
destroyed = true; timers.clearTimeout(timer); globalThis.removeEventListener?.('online', online);
|
||||
reject?.(new Error('REPORTER_CLOSED')); reject = undefined; resolve = undefined;
|
||||
},
|
||||
};
|
||||
}
|
||||
export type ResultReporter = ReturnType<typeof createResultReporter>;
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,165 @@
|
||||
/* Import after game.css, match.css and home.css. */
|
||||
@font-face {
|
||||
font-family: 'Black Han Sans';
|
||||
src: url('./fonts/BlackHanSans-Regular.ttf') format('truetype');
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
body.player-app {
|
||||
--ink: #242526;
|
||||
--muted: #666;
|
||||
--line: #d6d6d6;
|
||||
--ui-orange: #ff912b;
|
||||
--ui-display: 'Black Han Sans', 'Malgun Gothic', sans-serif;
|
||||
--ui-body: 'Segoe UI', 'Apple SD Gothic Neo', 'Malgun Gothic', sans-serif;
|
||||
background: #fff;
|
||||
color: var(--ink);
|
||||
font-family: var(--ui-body);
|
||||
}
|
||||
|
||||
body.player-app :is(h1, h2, button, summary),
|
||||
body.player-app #match .action-buttons strong {
|
||||
font-family: var(--ui-display);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
body.player-app button,
|
||||
body.player-app #match button,
|
||||
body.player-app #match .match-menu summary {
|
||||
border: 2px solid var(--ink);
|
||||
border-radius: 0;
|
||||
background: var(--ink);
|
||||
color: #fff;
|
||||
box-shadow: 4px 4px 0 #24252640;
|
||||
transition: transform .12s, background .12s, box-shadow .12s;
|
||||
}
|
||||
|
||||
body.player-app button.primary,
|
||||
body.player-app #match button.primary,
|
||||
body.player-app #match .action-buttons button[data-action=charge] {
|
||||
border-radius: 0;
|
||||
background: var(--ui-orange);
|
||||
color: var(--ink);
|
||||
box-shadow: 4px 4px 0 var(--ink);
|
||||
}
|
||||
|
||||
body.player-app button:hover:not(:disabled),
|
||||
body.player-app #match button:hover:not(:disabled),
|
||||
body.player-app #match .match-menu summary:hover {
|
||||
background: #ffbd43;
|
||||
border-color: var(--ink);
|
||||
color: var(--ink);
|
||||
transform: translate(-2px, -2px);
|
||||
}
|
||||
|
||||
body.player-app button:active:not(:disabled),
|
||||
body.player-app #match button:active:not(:disabled),
|
||||
body.player-app #match .match-menu summary:active {
|
||||
border-width: 2px;
|
||||
box-shadow: none;
|
||||
transform: translate(3px, 3px);
|
||||
}
|
||||
|
||||
body.player-app button:disabled,
|
||||
body.player-app #match button:disabled,
|
||||
body.player-app #match .action-buttons button[data-unavailable=true] {
|
||||
background: #e4e4e4;
|
||||
border-color: #aaa;
|
||||
color: #666;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
body.player-app #match .action-buttons button.chosen {
|
||||
background: var(--ui-orange);
|
||||
border-color: var(--ink);
|
||||
color: var(--ink);
|
||||
box-shadow: inset 0 0 0 2px #fff, 3px 3px 0 var(--ink);
|
||||
}
|
||||
|
||||
body.player-app :is(button, summary, input):focus-visible {
|
||||
outline: 3px solid #1967b8;
|
||||
outline-offset: 4px;
|
||||
}
|
||||
|
||||
body.player-app input { background: #fff; border-color: #777; border-radius: 0; color: var(--ink); }
|
||||
body.player-app #room-code { width: 0; min-width: 0; font-size: clamp(16px, 3vw, 22px); letter-spacing: .12em; }
|
||||
body.player-app .join-form button[type=submit] { flex-shrink: 0; }
|
||||
|
||||
body.player-app .waiting { padding-block: 32px; }
|
||||
body.player-app .waiting h1 { flex-wrap: wrap; }
|
||||
body.player-app #waiting-fighter { background: #ffb44a; }
|
||||
body.player-app .waiting-dots i { background: var(--ink); }
|
||||
body.player-app .waiting .room-code { background: #fff2df; border: 2px dashed var(--ink); border-radius: 0; font-size: clamp(28px, 8vw, 44px); letter-spacing: .15em; }
|
||||
body.player-app .waiting .split { flex-wrap: wrap; }
|
||||
body.player-app .waiting .split button { padding-inline: 12px; }
|
||||
body.player-app #waiting-leave { margin-top: 12px; }
|
||||
|
||||
body.player-app #match .action-buttons { background: transparent; border: 0; border-radius: 0; box-shadow: none; }
|
||||
body.player-app #match .action-buttons button { box-shadow: 3px 3px 0 #24252640; }
|
||||
body.player-app #match .action-buttons :is(button[data-action=charge], button.chosen) { box-shadow: 3px 3px 0 var(--ink); }
|
||||
body.player-app #match .action-buttons button:disabled:not(.chosen) { box-shadow: none; }
|
||||
body.player-app #match .action-buttons button[data-action=charge]:disabled:not(.chosen) { background: #e4e4e4; color: #666; }
|
||||
body.player-app #match .action-buttons small { color: inherit; font-family: var(--ui-body); font-weight: 400; }
|
||||
body.player-app #match .action-buttons .action-symbol { color: inherit; opacity: 1; filter: none; }
|
||||
body.player-app #match .energy > svg { color: var(--ink); }
|
||||
body.player-app #match .chosen-mark { background: var(--ink); border-color: #fff; color: #fff; border-radius: 0; }
|
||||
body.player-app #match .match-menu-items { background: #fff; border: 2px solid var(--ink); border-radius: 0; box-shadow: 4px 4px 0 #24252640; padding: 8px; }
|
||||
body.player-app #match .match-menu-items button + button { margin-top: 8px; }
|
||||
|
||||
/* Keep the footer in layout during combat so it never covers action buttons. */
|
||||
body.player-app .app > footer.app-footer {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
justify-content: flex-end;
|
||||
gap: 0;
|
||||
padding: 8px max(24px, env(safe-area-inset-right)) max(8px, env(safe-area-inset-bottom)) max(16px, env(safe-area-inset-left));
|
||||
background: #fff;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
body.player-app .app-footer nav { display: flex; align-items: center; gap: 4px; }
|
||||
body.player-app .app-footer nav > span { font-size: 12px; }
|
||||
body.player-app .app-footer #connection { min-height: 16px; font-size: 10px; color: var(--muted); }
|
||||
body.player-app .app-footer button.quiet {
|
||||
min-height: 44px;
|
||||
padding: 8px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #454545;
|
||||
box-shadow: none;
|
||||
font-size: 12px;
|
||||
transform: none;
|
||||
}
|
||||
body.player-app .app-footer button.quiet:hover { background: #fff0df; color: var(--ink); }
|
||||
body.player-app .app-footer button.quiet:active { background: #ffe2bd; transform: none; }
|
||||
body.player-app:is([data-screen=home], [data-screen=join]) .app-footer { position: absolute; right: 0; bottom: 0; background: transparent; }
|
||||
body.player-app:is([data-screen=match], [data-screen=result]) .app > footer.app-footer { display: flex; }
|
||||
|
||||
body.player-app dialog:not(.finish-cutscene) {
|
||||
padding: 24px;
|
||||
border: 2px solid var(--ink);
|
||||
border-radius: 0;
|
||||
background: #fff;
|
||||
color: var(--ink);
|
||||
box-shadow: 6px 6px 0 #24252680;
|
||||
}
|
||||
body.player-app dialog:not(.finish-cutscene)::backdrop { background: #24252699; }
|
||||
body.player-app dialog:not(.finish-cutscene) p { color: #4d4d4d; }
|
||||
body.player-app .dialog-top button { flex-shrink: 0; }
|
||||
body.player-app #contact-content { overflow-wrap: anywhere; }
|
||||
|
||||
@media (max-width: 760px) {
|
||||
body.player-app:is([data-screen=home], [data-screen=join]) .app > footer.app-footer { position: static; margin-top: auto; }
|
||||
body.player-app[data-screen=waiting] .app { padding-inline: max(20px, env(safe-area-inset-left)); }
|
||||
body.player-app[data-screen=waiting] .app > footer.app-footer { padding-inline: 0; }
|
||||
body.player-app dialog:not(.finish-cutscene) { padding: 18px; }
|
||||
body.player-app .dialog-top h2 { font-size: 19px; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
body.player-app button, body.player-app #match button, body.player-app #match .match-menu summary { transition: none; }
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { createHash, timingSafeEqual } from 'node:crypto';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { Analytics } from './analytics.js';
|
||||
import { getAnalyticsReport, validateFilters } from './analytics-report.js';
|
||||
import type { StatsStore } from './stats-store.js';
|
||||
import type { Clock } from './clock.js';
|
||||
import { RETENTION_MS } from '../../constants/analytics.js';
|
||||
export function registerAdmin(app: FastifyInstance, store: StatsStore, analytics: Analytics, clock: Clock, credentials: { user?: string; password?: string }, directory: string) {
|
||||
const digest = (value: string) => createHash('sha256').update(value).digest();
|
||||
const authorized = (header: string | undefined) => {
|
||||
if (!credentials.user || !credentials.password) return false;
|
||||
if (!header?.startsWith('Basic ') || header.length > 4096) return false;
|
||||
return timingSafeEqual(digest(Buffer.from(header.slice(6), 'base64').toString()), digest(`${credentials.user}:${credentials.password}`));
|
||||
};
|
||||
app.register(async admin => {
|
||||
admin.addHook('onRequest', async (request, reply) => {
|
||||
reply.header('Cache-Control', 'no-store');
|
||||
if (!credentials.user || !credentials.password) return reply.code(404).send({ error: '관리자 분석이 비활성화되었습니다.' });
|
||||
if (!authorized(request.headers.authorization)) return reply.header('WWW-Authenticate', 'Basic realm="analytics", charset="UTF-8"').code(401).send({ error: '관리자 인증이 필요합니다.' });
|
||||
});
|
||||
admin.get('/admin/analytics', (_request, reply) => reply.sendFile('admin/analytics/index.html', directory));
|
||||
admin.get('/api/admin/analytics/report', async (request, reply) => {
|
||||
let filters;
|
||||
try { filters = validateFilters(request.query as Record<string, unknown>, new Date(clock.now())); }
|
||||
catch { return reply.code(400).send({ error: '최근 90일의 시작·종료 시각과 필터를 확인하세요.' }); }
|
||||
const report = await getAnalyticsReport(store.db, filters, new Date(clock.now()));
|
||||
const collection = analytics.status();
|
||||
if (collection.incomplete) report.integrityIssues.push('이벤트 수집 누락 또는 저장 대기');
|
||||
return { ...report, live: analytics.live(), collection };
|
||||
});
|
||||
admin.get('/api/admin/analytics/live', async () => ({ live: analytics.live(), collection: analytics.status() }));
|
||||
admin.get('/api/admin/analytics/users/:id', async (request, reply) => {
|
||||
const id = (request.params as { id: string }).id, q = request.query as { page?: string; pageSize?: string };
|
||||
const page = Number(q.page ?? 1), pageSize = Number(q.pageSize ?? 20);
|
||||
if (!/^[a-zA-Z0-9_-]{1,80}$/.test(id) || !Number.isInteger(page) || page < 1 || page > 10000 || !Number.isInteger(pageSize) || pageSize < 1 || pageSize > 50) return reply.code(400).send({ error: '조회 범위를 확인하세요.' });
|
||||
const player = await store.db.collection('players').findOne({ _id: id as any }, { projection: { _id: 1, firstVisitAt: 1 } });
|
||||
if (!player) return reply.code(404).send({ error: '사용자를 찾을 수 없습니다.' });
|
||||
const cutoff = new Date(clock.now() - RETENTION_MS), skip = (page - 1) * pageSize;
|
||||
const [sessions, events, gameSessions] = await Promise.all([
|
||||
store.db.collection('analytics_sessions').find({ playerId: id, lastAt: { $gte: cutoff } }, { projection: { _id: 0 } }).sort({ startedAt: -1 }).skip(skip).limit(pageSize + 1).toArray(),
|
||||
store.db.collection('analytics_events').find({ playerId: id, occurredAt: { $gte: cutoff }, environment: 'production' }).sort({ occurredAt: -1 }).skip(skip).limit(pageSize + 1).toArray(),
|
||||
store.db.collection('game_sessions').find({ playerId: id, expiresAt: { $gt: new Date(clock.now()) }, environment: { $ne: 'test' } },
|
||||
{ projection: { _id: 1, stats: 1, createdAt: 1, updatedAt: 1, expiresAt: 1 } }).sort({ updatedAt: -1 }).skip(skip).limit(pageSize + 1).toArray(),
|
||||
]);
|
||||
return { playerId: id, firstVisitAt: player.firstVisitAt, lastVisitAt: sessions[0]?.lastAt,
|
||||
sessions: sessions.slice(0, pageSize), events: events.slice(0, pageSize), page, pageSize,
|
||||
gameSessions: gameSessions.slice(0, pageSize).map(({ _id, ...state }) => ({ gameSessionId: _id, ...state })),
|
||||
hasMore: [sessions, events, gameSessions].some(rows => rows.length > pageSize) };
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import type { Db } from 'mongodb';
|
||||
import { RETENTION_MS } from '../../constants/analytics.js';
|
||||
import type { AnalyticsEvent, AnalyticsFilters, Cohort, Ratio, VisitSession } from '../shared/analytics-types.js';
|
||||
import type { PlayerDocument } from './stats-store.js';
|
||||
export const kstDay = (date: Date) => new Date(+date + 9 * 3600000).toISOString().slice(0, 10);
|
||||
const dayMs = 86400000;
|
||||
const dayStart = (day: string) => +new Date(`${day}T00:00:00+09:00`);
|
||||
const unique = <T>(values: T[]) => new Set(values).size;
|
||||
export const ratio = (numerator: number, denominator: number): Ratio => ({ numerator, denominator, percent: denominator ? numerator / denominator * 100 : null });
|
||||
export function retention(first: { playerId: string; day: string }[], activity: { playerId: string; day: string }[], now: Date): Cohort[] {
|
||||
const groups = new Map<string, Set<string>>();
|
||||
for (const row of first) { if (!groups.has(row.day)) groups.set(row.day, new Set()); groups.get(row.day)!.add(row.playerId); }
|
||||
const active = new Set(activity.map(a => `${a.playerId}:${a.day}`));
|
||||
return [...groups.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([day, ids]) => {
|
||||
const rate = (offset: number) => {
|
||||
const target = dayStart(day) + offset * dayMs;
|
||||
if (target + dayMs > +now) return null;
|
||||
const targetDay = kstDay(new Date(target));
|
||||
return ratio([...ids].filter(id => active.has(`${id}:${targetDay}`)).length, ids.size);
|
||||
};
|
||||
return { day, size: ids.size, d1: rate(1), d7: rate(7) };
|
||||
});
|
||||
}
|
||||
export function validateFilters(query: Record<string, unknown>, now: Date): AnalyticsFilters {
|
||||
const end = query.end === undefined ? new Date(now) : new Date(String(query.end));
|
||||
const start = query.start === undefined ? new Date(+end - 7 * dayMs) : new Date(String(query.start));
|
||||
if (!Number.isFinite(+start) || !Number.isFinite(+end) || +start >= +end || +start < +now - RETENTION_MS || +end > +now + 1000) throw new Error('INVALID_RANGE');
|
||||
const allow: Record<string, string[]> = { mode: ['bot', 'friend'], audience: ['new', 'returning'], device: ['pc', 'mobile', 'tablet'], browser: ['Chrome', 'Edge', 'Firefox', 'Safari', 'Other'] };
|
||||
for (const [key, values] of Object.entries(allow)) if (query[key] && !values.includes(String(query[key]))) throw new Error('INVALID_FILTER');
|
||||
if (query.source && !/^[a-zA-Z0-9_.-]{1,80}$/.test(String(query.source))) throw new Error('INVALID_FILTER');
|
||||
return { start, end, ...Object.fromEntries(['mode', 'audience', 'device', 'browser', 'source'].filter(k => query[k]).map(k => [k, String(query[k])])) } as AnalyticsFilters;
|
||||
}
|
||||
export function median(values: number[]) {
|
||||
if (!values.length) return null;
|
||||
const sorted = [...values].sort((a, b) => a - b), i = Math.floor(sorted.length / 2);
|
||||
return sorted.length % 2 ? sorted[i] : (sorted[i - 1] + sorted[i]) / 2;
|
||||
}
|
||||
function distribution(values: number[], boundaries: number[]) {
|
||||
return [...boundaries, Infinity].map((upper, i) => ({ lower: i ? boundaries[i - 1] : 0, upper: Number.isFinite(upper) ? upper : null,
|
||||
count: values.filter(v => v >= (i ? boundaries[i - 1] : 0) && v < upper).length }));
|
||||
}
|
||||
export async function getAnalyticsReport(db: Db, filters: AnalyticsFilters, now: Date) {
|
||||
const cutoff = new Date(+now - RETENTION_MS), start = filters.start, end = filters.end;
|
||||
const allEvents = await db.collection<AnalyticsEvent>('analytics_events').aggregate<AnalyticsEvent>([
|
||||
{ $match: { occurredAt: { $gte: cutoff, $lt: now }, environment: 'production' } },
|
||||
{ $sort: { occurredAt: 1, _id: 1 } }, { $project: { expiresAt: 0 } },
|
||||
]).toArray();
|
||||
const players = await db.collection<PlayerDocument>('players').find({}, { projection: { _id: 1, firstVisitAt: 1 } }).toArray();
|
||||
const playerById = new Map(players.map(p => [p._id, p]));
|
||||
const sessions = await db.collection<VisitSession>('analytics_sessions').find({ lastAt: { $gte: cutoff } }, { projection: { _id: 0 } }).toArray();
|
||||
const sessionById = new Map(sessions.map(s => [s.sessionId, s]));
|
||||
const period = (date: Date) => +date >= +start && +date < +end;
|
||||
const qualifies = (playerId?: string, sessionId?: string) => {
|
||||
const s = sessionId ? sessionById.get(sessionId) : undefined;
|
||||
if (filters.device && s?.metadata.device !== filters.device) return false;
|
||||
if (filters.browser && !s?.metadata.browser.startsWith(filters.browser)) return false;
|
||||
if (filters.source && s?.metadata.source !== filters.source) return false;
|
||||
const first = playerId ? playerById.get(playerId)?.firstVisitAt : undefined;
|
||||
if (filters.audience && (!first || (filters.audience === 'new' ? !period(first) : +first >= +start))) return false;
|
||||
return true;
|
||||
};
|
||||
const events = allEvents.filter(e => period(e.occurredAt) && qualifies(e.playerId, e.sessionId));
|
||||
const clientGames = events.filter(e => e.source === 'client' && ['game_started', 'game_finished'].includes(e.eventType) && (!filters.mode || e.mode === filters.mode));
|
||||
const clientStarts = clientGames.filter(e => e.eventType === 'game_started');
|
||||
const clientResults = clientGames.filter(e => e.eventType === 'game_finished');
|
||||
const visits = events.filter(e => e.eventType === 'page_view');
|
||||
const visitors = new Set(visits.map(e => e.playerId!));
|
||||
const visitSessions = new Set(visits.map(e => e.sessionId!));
|
||||
const daily = new Map<string, { day: string; visitors: Set<string>; players: Set<string>; matches: Set<string>; pageViews: number }>();
|
||||
const hourly = new Map<string, { hour: string; visitors: Set<string>; matches: Set<string> }>();
|
||||
const bucket = (date: Date) => {
|
||||
const day = kstDay(date), hour = new Date(+date + 9 * 3600000).toISOString().slice(0, 13);
|
||||
if (!daily.has(day)) daily.set(day, { day, visitors: new Set(), players: new Set(), matches: new Set(), pageViews: 0 });
|
||||
if (!hourly.has(hour)) hourly.set(hour, { hour, visitors: new Set(), matches: new Set() });
|
||||
return { day: daily.get(day)!, hour: hourly.get(hour)! };
|
||||
};
|
||||
for (const v of visits) { const b = bucket(v.occurredAt); b.day.visitors.add(v.playerId!); b.hour.visitors.add(v.playerId!); b.day.pageViews++; }
|
||||
// Both participants report a friend match. Count the match at its first reported start.
|
||||
const counted = new Set<string>();
|
||||
for (const s of clientStarts) {
|
||||
const b = bucket(s.occurredAt); b.day.players.add(s.playerId!);
|
||||
if (!counted.has(s.matchId!)) { b.day.matches.add(s.matchId!); b.hour.matches.add(s.matchId!); counted.add(s.matchId!); }
|
||||
}
|
||||
const today = dayStart(kstDay(new Date(+end - 1)));
|
||||
const rolling = (days: number) => {
|
||||
const from = today - (days - 1) * dayMs, until = today + dayMs;
|
||||
const rows = allEvents.filter(e => +e.occurredAt >= from && +e.occurredAt < until && qualifies(e.playerId, e.sessionId));
|
||||
return { visitors: unique(rows.filter(e => e.eventType === 'page_view').map(e => e.playerId)),
|
||||
players: unique(rows.filter(e => e.source === 'client' && e.eventType === 'game_started' && (!filters.mode || e.mode === filters.mode)).map(e => e.playerId)) };
|
||||
};
|
||||
const grouping = (key: 'source' | 'device' | 'browser' | 'referrer') => {
|
||||
const groups = new Map<string, { visitors: Set<string>; pageViews: number }>();
|
||||
for (const e of visits) {
|
||||
const metadata = sessionById.get(e.sessionId!)?.metadata ?? e.metadata;
|
||||
const label = metadata?.[key] ?? (key === 'referrer' ? 'direct_or_unknown' : 'unknown');
|
||||
if (!groups.has(label)) groups.set(label, { visitors: new Set(), pageViews: 0 });
|
||||
const g = groups.get(label)!; g.visitors.add(e.playerId!); g.pageViews++;
|
||||
}
|
||||
return [...groups].map(([name, g]) => ({ name, visitors: g.visitors.size, pageViews: g.pageViews }));
|
||||
};
|
||||
const counts = (rows: AnalyticsEvent[], field: 'code' | 'action' | 'screen' | 'outcome') => Object.entries(rows.reduce<Record<string, number>>((out, e) => { const k = e[field] ?? 'unknown'; out[k] = (out[k] ?? 0) + 1; return out; }, {})).map(([name, count]) => ({ name, count }));
|
||||
const first = players.filter(p => p.firstVisitAt && period(p.firstVisitAt) && sessions.some(s => s.playerId === p._id && qualifies(s.playerId, s.sessionId)) && allEvents.some(e => e.playerId === p._id && e.eventType === 'page_view'));
|
||||
const visitActivity = sessions.filter(s => qualifies(s.playerId, s.sessionId)).flatMap(s => (s.activeDays ?? []).map(day => ({ playerId: s.playerId, day })));
|
||||
const samples = await db.collection('analytics_presence_samples').aggregate([
|
||||
{ $match: { occurredAt: { $gte: start, $lt: end }, environment: 'production', source: 'activity_reports' } },
|
||||
{ $group: { _id: null, count: { $sum: 1 }, active: { $max: '$active' } } }, { $project: { _id: 0 } },
|
||||
]).toArray();
|
||||
const metadata = await db.collection('analytics_metadata').findOne({ _id: 'production' as any });
|
||||
const quality = await db.collection('analytics_presence_samples').aggregate([
|
||||
{ $match: { occurredAt: { $gte: start, $lt: end }, environment: 'production', source: 'activity_reports' } },
|
||||
{ $group: { _id: '$restartedAt', failures: { $max: '$failures' }, dropped: { $max: '$dropped' }, rateLimited: { $max: '$rateLimited' } } },
|
||||
{ $group: { _id: null, failures: { $sum: '$failures' }, dropped: { $sum: '$dropped' }, rateLimited: { $sum: '$rateLimited' } } }, { $project: { _id: 0 } },
|
||||
]).toArray();
|
||||
const historicalCollection = quality[0] ?? { failures: 0, dropped: 0, rateLimited: 0 };
|
||||
const integrityIssues: string[] = [];
|
||||
if (historicalCollection.failures || historicalCollection.dropped || historicalCollection.rateLimited) integrityIssues.push('조회 기간의 수집 실패·버림·제한 이력');
|
||||
const newCount = [...visitors].filter(id => { const date = playerById.get(id)?.firstVisitAt; return date && period(date); }).length;
|
||||
const activeTimes = sessions.filter(s => visitSessions.has(s.sessionId)).map(s => s.activeMs);
|
||||
return {
|
||||
range: { start, end, timezone: 'Asia/Seoul', retentionStart: cutoff, collectionStart: metadata?.firstCollectedAt ?? allEvents[0]?.occurredAt ?? null },
|
||||
filterApplicability: { visits: '모드 필터 미적용', live: '모든 과거 기간·분류 필터 미적용', activeTime: '세션 전체 누적값, 기간 경계에서 분할하지 않음' },
|
||||
integrityIssues, historicalCollection,
|
||||
overview: { visitors: visitors.size, visitSessions: visitSessions.size, pageViews: visits.length,
|
||||
newVisitors: ratio(newCount, visitors.size), returningVisitors: ratio(visitors.size - newCount, visitors.size), dau: rolling(1), wau: rolling(7), mau: rolling(30) },
|
||||
daily: [...daily.values()].sort((a, b) => a.day.localeCompare(b.day)).map(d => ({ ...d, visitors: d.visitors.size, players: d.players.size, matches: d.matches.size })),
|
||||
hourly: [...hourly.values()].sort((a, b) => a.hour.localeCompare(b.hour)).map(h => ({ ...h, visitors: h.visitors.size, matches: h.matches.size })),
|
||||
sampledPeak: samples[0] ?? { count: 0, active: null },
|
||||
acquisition: { source: grouping('source'), device: grouping('device'), browser: grouping('browser'), referrer: grouping('referrer'),
|
||||
invitation: { invited: visits.filter(v => sessionById.get(v.sessionId!)?.metadata.invitation).length, total: visits.length } },
|
||||
flow: { screens: counts(events.filter(e => e.eventType === 'screen_view'), 'screen'), actions: counts(events.filter(e => e.eventType === 'ui_action'), 'action') },
|
||||
revisit: { visitCohorts: retention(first.map(p => ({ playerId: p._id, day: kstDay(p.firstVisitAt!) })), visitActivity, now),
|
||||
activeMs: { median: median(activeTimes), distribution: distribution(activeTimes, [30000, 60000, 300000, 600000]) } },
|
||||
clientGameplay: { source: 'client' as const, players: unique(clientGames.map(e => e.playerId)),
|
||||
startedMatches: unique(clientStarts.map(e => e.matchId)), resultReports: clientResults.length,
|
||||
outcomes: counts(clientResults, 'outcome'), actions: null, combinations: null, turns: null, serverVerifiedCompletion: null },
|
||||
errors: { client: counts(events.filter(e => e.eventType === 'client_error'), 'code') },
|
||||
users: [...visitors].map(id => ({ playerId: id, firstVisitAt: playerById.get(id)?.firstVisitAt,
|
||||
lastVisitAt: sessions.filter(s => s.playerId === id).sort((a, b) => +b.lastAt - +a.lastAt)[0]?.lastAt,
|
||||
clientMatches: unique(clientStarts.filter(e => e.playerId === id).map(e => e.matchId)) })).slice(0, 50),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { MongoServerError, type Db } from 'mongodb';
|
||||
import { ACTIVE_WINDOW_MS, RETENTION_MS, VISIT_SESSION_MS } from '../../constants/analytics.js';
|
||||
import type { AnalyticsEvent, Presence, VisitMetadata, VisitSession } from '../shared/analytics-types.js';
|
||||
import type { PlayerDocument } from './stats-store.js';
|
||||
import { kstDay } from './analytics-report.js';
|
||||
export interface AnalyticsStore {
|
||||
db: Db;
|
||||
touch(playerId: string, now: Date, visible: boolean, metadata: VisitMetadata, metadataFinal?: boolean): Promise<VisitSession>;
|
||||
insert(event: AnalyticsEvent): Promise<void>;
|
||||
sample(now: Date, presence: Presence, environment: string, quality?: { restartedAt: Date; failures: number; dropped: number; rateLimited: number }): Promise<void>;
|
||||
cleanup(now: Date): Promise<void>;
|
||||
}
|
||||
export async function createAnalyticsStore(db: Db): Promise<AnalyticsStore> {
|
||||
const players = db.collection<PlayerDocument & { visit?: VisitSession }>('players');
|
||||
const sessions = db.collection<VisitSession & { _id: string }>('analytics_sessions');
|
||||
const events = db.collection<AnalyticsEvent>('analytics_events');
|
||||
const samples = db.collection('analytics_presence_samples');
|
||||
for (const collection of [sessions, events, samples]) await collection.createIndex({ expiresAt: 1 }, { expireAfterSeconds: 0 });
|
||||
await sessions.createIndex({ playerId: 1, lastAt: -1 });
|
||||
await sessions.createIndex({ sessionId: 1 }, { unique: true });
|
||||
for (const field of ['eventType', 'playerId', 'sessionId']) await events.createIndex({ [field]: 1, occurredAt: 1 });
|
||||
await samples.createIndex({ occurredAt: 1 });
|
||||
await players.createIndex({ 'visit.expiresAt': 1 });
|
||||
const cleanup = async (now: Date) => { await players.updateMany({ 'visit.expiresAt': { $lte: now } }, { $unset: { visit: '' } }); };
|
||||
await cleanup(new Date());
|
||||
return {
|
||||
db,
|
||||
cleanup,
|
||||
async touch(playerId, now, visible, metadata, metadataFinal = false) {
|
||||
// The single player document atomically decides the session ID and activity delta.
|
||||
// Snapshot writes to analytics_sessions never decide or increment counters independently.
|
||||
const expired = { $or: [{ $eq: [{ $type: '$visit' }, 'missing'] }, { $lte: ['$visit.lastAt', new Date(+now - VISIT_SESSION_MS)] }] };
|
||||
const fresh: VisitSession = { sessionId: randomUUID(), playerId, startedAt: now, lastAt: now, lastSignalAt: now, visible,
|
||||
activeMs: 0, activeDays: visible ? [kstDay(now)] : [], metadata, metadataReady: metadataFinal, expiresAt: new Date(+now + RETENTION_MS) };
|
||||
const delta = { $cond: [{ $and: [visible, '$visit.visible', { $lte: [{ $subtract: [now, '$visit.lastSignalAt'] }, ACTIVE_WINDOW_MS] }, { $gte: [now, '$visit.lastSignalAt'] }] }, { $subtract: [now, '$visit.lastSignalAt'] }, 0] };
|
||||
const doc = await players.findOneAndUpdate({ _id: playerId }, [{ $set: {
|
||||
visit: { $cond: [expired, { $literal: fresh }, { $mergeObjects: ['$visit', {
|
||||
visible, lastSignalAt: now, lastAt: visible ? now : '$visit.lastAt',
|
||||
activeMs: { $add: ['$visit.activeMs', delta] }, expiresAt: new Date(+now + RETENTION_MS),
|
||||
activeDays: { $setUnion: [{ $filter: { input: { $ifNull: ['$visit.activeDays', []] }, as: 'day', cond: { $gte: ['$$day', kstDay(new Date(+now - RETENTION_MS))] } } }, visible ? [kstDay(now)] : []] },
|
||||
metadata: { $cond: [{ $and: [metadataFinal, { $ne: ['$visit.metadataReady', true] }] }, { $literal: metadata }, '$visit.metadata'] },
|
||||
metadataReady: { $or: [metadataFinal, { $eq: ['$visit.metadataReady', true] }] },
|
||||
}] }] },
|
||||
} }], { returnDocument: 'after' });
|
||||
if (!doc?.visit) throw new Error('UNKNOWN_PLAYER');
|
||||
const visit = doc.visit;
|
||||
await sessions.updateOne({ _id: visit.sessionId }, {
|
||||
$setOnInsert: { sessionId: visit.sessionId, playerId, startedAt: visit.startedAt, ...(!visit.metadataReady ? { metadata: visit.metadata } : {}) },
|
||||
$max: { lastAt: visit.lastAt, lastSignalAt: visit.lastSignalAt, activeMs: visit.activeMs, expiresAt: visit.expiresAt },
|
||||
$set: { visible: visit.visible, ...(visit.metadataReady ? { metadata: visit.metadata, metadataReady: true } : {}) },
|
||||
$addToSet: { activeDays: { $each: visit.activeDays } },
|
||||
}, { upsert: true });
|
||||
return visit;
|
||||
},
|
||||
async insert(event) {
|
||||
try { await events.insertOne({ ...event, expiresAt: new Date(+event.occurredAt + RETENTION_MS) }); }
|
||||
catch (error) { if (!(error instanceof MongoServerError) || error.code !== 11000) throw error; }
|
||||
await db.collection<{ _id: string; firstCollectedAt: Date }>('analytics_metadata').updateOne({ _id: event.environment }, { $min: { firstCollectedAt: event.occurredAt } }, { upsert: true });
|
||||
if (event.eventType === 'page_view' && event.playerId) await players.updateOne({ _id: event.playerId }, { $min: { firstVisitAt: event.occurredAt } });
|
||||
},
|
||||
async sample(now, presence, environment, quality) { await samples.insertOne({ occurredAt: now, active: presence.active, source: presence.source, environment, ...(quality ?? {}), expiresAt: new Date(+now + RETENTION_MS) }); },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { ACTIVITY_INTERVAL_MS, ACTIVE_WINDOW_MS, CAMPAIGN_VALUES, CLIENT_ERROR_CODES, CLIENT_EVENTS, MAX_EVENTS_PER_MINUTE, PRESENCE_INTERVAL_MS, RETENTION_SWEEP_MS, RETRY_INTERVAL_MS, RETRY_MAX_AGE_MS, RETRY_QUEUE_LIMIT, SCREENS, UI_ACTIONS } from '../../constants/analytics.js';
|
||||
import { APP_VERSION } from '../../constants/gameplay.js';
|
||||
import type { AnalyticsEvent, Presence, VisitMetadata, VisitSession } from '../shared/analytics-types.js';
|
||||
import type { AnalyticsStore } from './analytics-store.js';
|
||||
import type { Clock } from './clock.js';
|
||||
import { reject } from './errors.js';
|
||||
const validId = (id: unknown): id is string => typeof id === 'string' && /^[A-Za-z0-9_-]{1,80}$/.test(id);
|
||||
export function normalizeMetadata(value: unknown): VisitMetadata {
|
||||
const v = value && typeof value === 'object' ? value as Record<string, unknown> : {};
|
||||
let referrer: string | undefined;
|
||||
try { const url = new URL(String(v.referrer)); if (['http:', 'https:'].includes(url.protocol)) referrer = url.hostname.toLowerCase().slice(0, 253); } catch {}
|
||||
const campaign = (key: string) => CAMPAIGN_VALUES.includes(v[key] as any) ? String(v[key]) : v[key] ? 'other' : undefined;
|
||||
const browser = typeof v.browser === 'string' && /^(Chrome|Edge|Firefox|Safari|Other)( [0-9]{1,3})?$/.test(v.browser) ? v.browser : 'Other';
|
||||
return { device: ['pc', 'mobile', 'tablet'].includes(String(v.device)) ? String(v.device) : 'pc', browser,
|
||||
source: campaign('source') ?? referrer ?? 'direct_or_unknown', invitation: v.invitation === true,
|
||||
...(referrer ? { referrer } : {}), ...(campaign('medium') ? { medium: campaign('medium') } : {}), ...(campaign('campaign') ? { campaign: campaign('campaign') } : {}) };
|
||||
}
|
||||
export interface AnalyticsIdentity { playerId: string; sessionId?: string; gameSessionId?: string }
|
||||
interface Activity { visible: boolean; lastAt: number; touchedAt?: number; hiddenAt?: number; visit?: VisitSession; metadata: VisitMetadata }
|
||||
interface Job { id: string; receivedAt: number; run: () => Promise<void>; waiters: { resolve: () => void; reject: () => void }[] }
|
||||
export class Analytics {
|
||||
states = new Map<string, Activity>();
|
||||
queue: Job[] = []; running = false; closed = false; timer?: any; sampleTimer?: any;
|
||||
httpRequests = 0;
|
||||
lastCleanup = 0;
|
||||
windows = new Map<string, { at: number; count: number }>();
|
||||
quality = { restartedAt: new Date(), lastSuccessAt: null as Date | null, failures: 0, dropped: 0, rateLimited: 0, retryFailures: 0, resultSaveFailures: 0 };
|
||||
constructor(private store: AnalyticsStore, private clock: Clock, private environment = 'production') {
|
||||
this.quality.restartedAt = new Date(clock.now()); this.scheduleSample();
|
||||
}
|
||||
private activityState(p: AnalyticsIdentity) {
|
||||
let state = this.states.get(p.playerId);
|
||||
if (!state) { state = { visible: true, lastAt: this.clock.now(), metadata: normalizeMetadata({}) }; this.states.set(p.playerId, state); }
|
||||
return state;
|
||||
}
|
||||
private async touch(p: AnalyticsIdentity, at: number, visible: boolean, metadataFinal = false) {
|
||||
const state = this.activityState(p);
|
||||
const metadataPending = metadataFinal && !state.visit?.metadataReady;
|
||||
const hideDue = !visible && (state.hiddenAt === undefined || at - state.hiddenAt >= ACTIVITY_INTERVAL_MS);
|
||||
if (state.visit && !metadataPending && !hideDue && at - (state.touchedAt ?? -Infinity) < ACTIVITY_INTERVAL_MS) { p.sessionId = state.visit.sessionId; return state.visit; }
|
||||
const visit = await this.store.touch(p.playerId, new Date(at), visible, state.metadata, metadataFinal);
|
||||
state.touchedAt = at; if (!visible) state.hiddenAt = at;
|
||||
state.visit = visit; p.sessionId = visit.sessionId; return visit;
|
||||
}
|
||||
private permitted(playerId: string) {
|
||||
const now = this.clock.now(); let w = this.windows.get(playerId);
|
||||
if (!w || now - w.at >= 60000) { w = { at: now, count: 0 }; this.windows.set(playerId, w); }
|
||||
if (++w.count > MAX_EVENTS_PER_MINUTE) { this.quality.rateLimited++; return false; } return true;
|
||||
}
|
||||
async clientEvent(p: AnalyticsIdentity, value: unknown) {
|
||||
const at = this.clock.now();
|
||||
const data = value as Record<string, unknown>;
|
||||
if (!data || !validId(data.eventId) || !CLIENT_EVENTS.includes(data.eventType as any)) reject('INVALID_ANALYTICS_EVENT', '수집 항목이 올바르지 않습니다.');
|
||||
const allowed = ['requestId', 'eventId', 'eventType', 'delayed', ...({
|
||||
page_view: ['screen', 'metadata'], screen_view: ['screen'], ui_action: ['screen', 'action', 'flowId'], client_error: ['screen', 'code'],
|
||||
game_started: ['matchId', 'mode'], game_finished: ['matchId', 'mode', 'outcome', 'reason'],
|
||||
} as Record<string, string[]>)[String(data.eventType)]];
|
||||
if (Object.keys(data).some(k => !allowed.includes(k))) reject('INVALID_ANALYTICS_EVENT', '허용되지 않은 수집 항목입니다.');
|
||||
if (data.screen !== undefined && !SCREENS.includes(data.screen as any)) reject('INVALID_ANALYTICS_EVENT', '화면을 확인하세요.');
|
||||
if (data.eventType === 'screen_view' && !SCREENS.includes(data.screen as any)) reject('INVALID_ANALYTICS_EVENT', '화면이 필요합니다.');
|
||||
if (data.delayed !== undefined && typeof data.delayed !== 'boolean') reject('INVALID_ANALYTICS_EVENT', '수집 상태를 확인하세요.');
|
||||
if (data.eventType === 'page_view' && data.screen !== 'home') reject('INVALID_ANALYTICS_EVENT', '시작 화면 조회만 수집합니다.');
|
||||
if (data.eventType === 'ui_action' && (!UI_ACTIONS.includes(data.action as any) || !validId(data.flowId))) reject('INVALID_ANALYTICS_EVENT', '조작을 확인하세요.');
|
||||
if (data.eventType === 'client_error' && !CLIENT_ERROR_CODES.includes(data.code as any)) reject('INVALID_ANALYTICS_EVENT', '오류 코드를 확인하세요.');
|
||||
const gameSummary = data.eventType === 'game_started' || data.eventType === 'game_finished';
|
||||
if (gameSummary && (!validId(data.matchId) || !['bot', 'friend'].includes(String(data.mode)))) reject('INVALID_ANALYTICS_EVENT', '대전을 확인하세요.');
|
||||
if (data.eventType === 'game_finished' && (!['win', 'loss', 'draw', 'aborted'].includes(String(data.outcome)) || !['hit', 'timeout', 'double_timeout', 'forfeit', 'connection_failed', 'protocol_error'].includes(String(data.reason)))) reject('INVALID_ANALYTICS_EVENT', '결과를 확인하세요.');
|
||||
const state = this.activityState(p);
|
||||
if (data.eventType === 'page_view' && !state.visit?.metadataReady) state.metadata = normalizeMetadata(data.metadata);
|
||||
const id = gameSummary && p.gameSessionId ? `game:${p.gameSessionId}:${data.matchId}:${data.eventType}` : `${p.playerId}:${data.eventId}`;
|
||||
if (!this.permitted(p.playerId)) reject('ANALYTICS_RATE_LIMIT', '수집 요청이 제한되었습니다.');
|
||||
state.lastAt = at;
|
||||
let acknowledgedSession: string | undefined;
|
||||
await this.enqueue(id, at, async () => {
|
||||
const existing = await this.store.db.collection<AnalyticsEvent>('analytics_events').findOne({ _id: id });
|
||||
if (existing) { acknowledgedSession = existing.sessionId; return; }
|
||||
const visit = await this.touch(p, at, state.visible, data.eventType === 'page_view');
|
||||
acknowledgedSession = visit.sessionId;
|
||||
const e: AnalyticsEvent = { _id: id, playerId: p.playerId, sessionId: visit.sessionId, occurredAt: new Date(at), eventType: String(data.eventType),
|
||||
appVersion: APP_VERSION, schemaVersion: 1, environment: this.environment, metadata: visit.metadata,
|
||||
source: 'client', ...(p.gameSessionId ? { gameSessionId: p.gameSessionId } : {}),
|
||||
...(gameSummary ? { matchId: String(data.matchId), mode: data.mode as 'bot' | 'friend' } : {}),
|
||||
...(data.eventType === 'game_finished' ? { outcome: data.outcome as AnalyticsEvent['outcome'], reason: String(data.reason) } : {}),
|
||||
...(data.screen ? { screen: String(data.screen) } : {}), ...(data.action ? { action: String(data.action) } : {}),
|
||||
...(data.code ? { code: String(data.code) } : {}), ...(validId(data.flowId) ? { flowId: data.flowId } : {}), delayed: data.delayed === true };
|
||||
await this.store.insert(e);
|
||||
});
|
||||
return { eventId: data.eventId, sessionId: acknowledgedSession ?? state.visit?.sessionId };
|
||||
}
|
||||
async clientActivity(p: AnalyticsIdentity, value: unknown) {
|
||||
const data = value as { visible?: unknown };
|
||||
if (!data || typeof data.visible !== 'boolean') reject('INVALID_ANALYTICS_EVENT', '화면 상태를 확인하세요.');
|
||||
const s = this.activityState(p), at = this.clock.now(); s.visible = data.visible;
|
||||
if (data.visible) s.lastAt = at;
|
||||
await this.enqueue(`activity:${p.playerId}:${at}:${data.visible}`, at, async () => { await this.touch(p, at, data.visible as boolean); });
|
||||
return { received: true };
|
||||
}
|
||||
enqueue(id: string, receivedAt: number, run: () => Promise<void>): Promise<void> {
|
||||
let job = this.queue.find(j => j.id === id);
|
||||
if (!job) {
|
||||
if (this.closed || this.queue.length >= RETRY_QUEUE_LIMIT) { this.quality.dropped++; return Promise.reject(new Error('QUEUE_FULL')); }
|
||||
job = { id, receivedAt, run, waiters: [] }; this.queue.push(job);
|
||||
}
|
||||
const promise = new Promise<void>((resolve, reject) => job!.waiters.push({ resolve, reject: () => reject(new Error('COLLECTION_FAILED')) }));
|
||||
void this.drain(); return promise;
|
||||
}
|
||||
private async drain() {
|
||||
if (this.running || this.closed || this.timer) return;
|
||||
this.running = true;
|
||||
while (this.queue.length && !this.closed) {
|
||||
const job = this.queue[0];
|
||||
if (this.clock.now() - job.receivedAt > RETRY_MAX_AGE_MS) { this.quality.dropped++; job.waiters.forEach(w => w.reject()); this.queue.shift(); continue; }
|
||||
try {
|
||||
await job.run(); this.quality.lastSuccessAt = new Date(this.clock.now()); job.waiters.forEach(w => w.resolve()); this.queue.shift();
|
||||
} catch {
|
||||
this.quality.failures++; this.quality.retryFailures++;
|
||||
job.waiters.forEach(w => w.reject()); job.waiters = [];
|
||||
this.timer = this.clock.setTimeout(() => { this.timer = undefined; void this.drain(); }, RETRY_INTERVAL_MS); break;
|
||||
}
|
||||
}
|
||||
this.running = false;
|
||||
}
|
||||
live(): Presence {
|
||||
const values = [...this.states.values()];
|
||||
return { connected: null, waitingRooms: null, playingPeople: null, matches: null,
|
||||
active: values.filter(s => s.visible && this.clock.now() - s.lastAt <= ACTIVE_WINDOW_MS).length,
|
||||
source: 'activity_reports', observedAt: new Date(this.clock.now()), lastActivityAt: values.length ? new Date(Math.max(...values.map(s => s.lastAt))) : null };
|
||||
}
|
||||
status() { return { ...this.quality, queued: this.queue.length, httpRequests: this.httpRequests, environment: this.environment, incomplete: !!(this.quality.dropped || this.quality.failures || this.quality.rateLimited || this.queue.length), now: new Date(this.clock.now()) }; }
|
||||
private scheduleSample() {
|
||||
this.sampleTimer = this.clock.setTimeout(() => {
|
||||
const at = this.clock.now(), presence = this.live(), quality = { restartedAt: this.quality.restartedAt, failures: this.quality.failures, dropped: this.quality.dropped, rateLimited: this.quality.rateLimited };
|
||||
void this.enqueue(`sample:${at}`, at, () => this.store.sample(new Date(at), presence, this.environment, quality)).catch(() => {});
|
||||
if (at - this.lastCleanup >= RETENTION_SWEEP_MS) {
|
||||
this.lastCleanup = at; void this.enqueue(`cleanup:${at}`, at, () => this.store.cleanup(new Date(at))).catch(() => {});
|
||||
}
|
||||
if (!this.closed) this.scheduleSample();
|
||||
// Bound idle identity caches without keeping an inactive visit alive.
|
||||
for (const [id, s] of this.states) if (at - s.lastAt > RETRY_MAX_AGE_MS) { this.states.delete(id); this.windows.delete(id); }
|
||||
}, PRESENCE_INTERVAL_MS);
|
||||
}
|
||||
async close() {
|
||||
this.closed = true; this.clock.clearTimeout(this.timer); this.clock.clearTimeout(this.sampleTimer);
|
||||
for (const j of this.queue) j.waiters.forEach(w => w.reject());
|
||||
while (this.running) await new Promise(r => setTimeout(r, 5));
|
||||
this.queue = []; this.states.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export interface Clock {
|
||||
now(): number;
|
||||
setTimeout(callback: () => void, delay: number): any;
|
||||
clearTimeout(timer: any): void;
|
||||
}
|
||||
export const systemClock: Clock = { now: Date.now, setTimeout, clearTimeout };
|
||||
@@ -0,0 +1,54 @@
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import fastifyStatic from '@fastify/static';
|
||||
import { Server } from 'socket.io';
|
||||
import { resolve } from 'node:path';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { createStatsStore } from './stats-store.js';
|
||||
import { systemClock, type Clock } from './clock.js';
|
||||
import { createAnalyticsStore } from './analytics-store.js';
|
||||
import { Analytics } from './analytics.js';
|
||||
import { registerAdmin } from './admin.js';
|
||||
import { createGameSessionStore } from './game-session-store.js';
|
||||
import { createRoomStore } from './rooms.js';
|
||||
import { registerAnalyticsApi, registerGameApi, registerRoomApi } from './game-api.js';
|
||||
import { registerSignaling, type RtcServerOptions } from './signaling.js';
|
||||
import { createRankedService } from './ranked-service.js';
|
||||
import { registerRankedApi } from './ranked-api.js';
|
||||
export interface ServerOptions { mongoUri: string; databaseName: string; clientDirectory: string; clock?: Clock; adminUser?: string; adminPassword?: string; environment?: 'production' | 'test'; rtc?: RtcServerOptions }
|
||||
export async function createGameServer(options: ServerOptions): Promise<FastifyInstance> {
|
||||
const store = await createStatsStore(options);
|
||||
const app = Fastify({ logger: false, bodyLimit: 16384 });
|
||||
const io = new Server(app.server, { maxHttpBufferSize: 16384 });
|
||||
const clock = options.clock ?? systemClock;
|
||||
const gameSessions = await createGameSessionStore(store.db, () => clock.now(), options.environment ?? 'production');
|
||||
const peerRooms = await createRoomStore(store.db, () => clock.now());
|
||||
const ranked = await createRankedService(store.db, clock, options.environment ?? 'production');
|
||||
const signaling = registerSignaling(io, gameSessions, peerRooms, options.rtc, () => clock.now());
|
||||
registerGameApi(app, gameSessions, store, ranked);
|
||||
registerRoomApi(app, gameSessions, peerRooms, signaling, ranked);
|
||||
registerRankedApi(app, gameSessions, ranked);
|
||||
const analyticsStore = await createAnalyticsStore(store.db);
|
||||
const analytics = new Analytics(analyticsStore, clock, options.environment ?? 'production');
|
||||
registerAnalyticsApi(app, gameSessions, analytics);
|
||||
app.addHook('onRequest', async request => { if (!request.url.startsWith('/api/admin/') && !request.url.startsWith('/admin/')) analytics.httpRequests++; });
|
||||
io.use((_socket, next) => next(new Error('USE_SIGNALING')));
|
||||
app.get('/health', async () => ({ status: 'ok' }));
|
||||
const directory = resolve(options.clientDirectory);
|
||||
await app.register(fastifyStatic, { root: directory, serve: false });
|
||||
if (existsSync(directory)) {
|
||||
app.get('/', (_request, reply) => reply.sendFile('index.html'));
|
||||
app.get('/assets/*', (request, reply) => {
|
||||
const path = (request.params as { '*': string })['*'];
|
||||
if (!/^[a-zA-Z0-9_.-]+$/.test(path)) return reply.code(404).send();
|
||||
return reply.sendFile(`assets/${path}`);
|
||||
});
|
||||
}
|
||||
registerAdmin(app, store, analytics, clock, { user: options.adminUser, password: options.adminPassword }, directory);
|
||||
app.addHook('preClose', async () => {
|
||||
await ranked.close();
|
||||
await analytics.close();
|
||||
await new Promise<void>(done => io.close(() => done()));
|
||||
});
|
||||
app.addHook('onClose', async () => { await store.close(); });
|
||||
return app;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export class GameError extends Error {
|
||||
constructor(public code: string, message: string) { super(message); }
|
||||
}
|
||||
export function reject(code: string, message: string): never { throw new GameError(code, message); }
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { FastifyInstance, FastifyRequest } from 'fastify';
|
||||
import { GAME_API_MESSAGES } from '../../constants/recording.js';
|
||||
import type { GameSessionAuth, ResultSubmission } from '../shared/p2p-types.js';
|
||||
import { ApiError, type GameSessionStore, type RegisterMatch } from './game-session-store.js';
|
||||
import type { RoomStore } from './rooms.js';
|
||||
import type { Signaling } from './signaling.js';
|
||||
import type { StatsStore } from './stats-store.js';
|
||||
import type { Analytics } from './analytics.js';
|
||||
import { GameError } from './errors.js';
|
||||
import { ANALYTICS_BATCH_SIZE } from '../../constants/analytics.js';
|
||||
import type { RankedService } from './ranked-service.js';
|
||||
|
||||
export function gameAuth(request: FastifyRequest): GameSessionAuth {
|
||||
const token = request.headers.authorization?.match(/^Bearer ([A-Za-z0-9_-]{43})$/)?.[1];
|
||||
const gameSessionId = request.headers['x-game-session'];
|
||||
if (!token || typeof gameSessionId !== 'string') throw new ApiError(401, 'INVALID_SESSION');
|
||||
return { gameSessionId, token };
|
||||
}
|
||||
export function gameRoute(app: FastifyInstance, method: 'GET' | 'POST', url: string, handler: (request: FastifyRequest) => unknown | Promise<unknown>) {
|
||||
app.route({ method, url, handler: async (request, reply) => {
|
||||
reply.header('Cache-Control', 'no-store');
|
||||
try { return { ok: true, data: await handler(request) }; }
|
||||
catch (error) {
|
||||
const code = error instanceof ApiError || error instanceof GameError ? error.code : 'SERVER_ERROR';
|
||||
const status = error instanceof ApiError ? error.statusCode : error instanceof GameError ? (code === 'ANALYTICS_RATE_LIMIT' ? 429 : 400) : 500;
|
||||
return reply.code(status).send({ ok: false, code, message: GAME_API_MESSAGES[code] ?? '요청을 확인하고 다시 시도하세요.' });
|
||||
}
|
||||
} });
|
||||
}
|
||||
export function registerAnalyticsApi(app: FastifyInstance, sessions: GameSessionStore, analytics: Analytics) {
|
||||
const identity = async (request: FastifyRequest) => {
|
||||
const session = await sessions.get(gameAuth(request));
|
||||
if (!session.playerId) throw new ApiError(401, 'INVALID_SESSION');
|
||||
return { playerId: session.playerId, gameSessionId: session.gameSessionId };
|
||||
};
|
||||
gameRoute(app, 'POST', '/api/analytics/events', async request => {
|
||||
const p = await identity(request), events = (request.body as any)?.events;
|
||||
if (!Array.isArray(events) || !events.length || events.length > ANALYTICS_BATCH_SIZE) throw new ApiError(400, 'INVALID_ANALYTICS_EVENT');
|
||||
const results = [];
|
||||
for (const event of events) results.push(await analytics.clientEvent(p, event));
|
||||
return { events: results };
|
||||
});
|
||||
gameRoute(app, 'POST', '/api/analytics/activity', async request => analytics.clientActivity(await identity(request), request.body));
|
||||
}
|
||||
export function registerGameApi(app: FastifyInstance, store: GameSessionStore, visitors?: StatsStore, ranked?: RankedService) {
|
||||
gameRoute(app, 'POST', '/api/game-sessions', async request => {
|
||||
const body = request.body as { createRequestId?: string; guestToken?: string };
|
||||
if (typeof body?.createRequestId !== 'string' || !/^[A-Za-z0-9_-]{43}$/.test(body.createRequestId)) throw new ApiError(400, 'INVALID_CREATION_KEY');
|
||||
const guest = visitors ? (typeof body.guestToken === 'string' ? await visitors.findGuest(body.guestToken) : null) ?? await visitors.createGuest(body.createRequestId) : undefined;
|
||||
const state = await store.create(body?.createRequestId ?? '', guest?.playerId);
|
||||
return { ...state, ...(guest?.playerId === state.playerId && guest ? { guestToken: guest.guestToken } : {}) };
|
||||
});
|
||||
gameRoute(app, 'GET', '/api/game-session', request => store.get(gameAuth(request)));
|
||||
gameRoute(app, 'POST', '/api/game-session/matches', async request => {
|
||||
const auth = gameAuth(request), state = await store.get(auth);
|
||||
const register = () => store.register(auth, request.body as RegisterMatch);
|
||||
return state.playerId && ranked ? ranked.withAvailable(state.playerId, register) : register();
|
||||
});
|
||||
gameRoute(app, 'POST', '/api/game-session/results', request => store.record(gameAuth(request), request.body as ResultSubmission));
|
||||
}
|
||||
export function registerRoomApi(app: FastifyInstance, store: GameSessionStore, rooms: RoomStore, signaling: Signaling, ranked?: RankedService) {
|
||||
const participant = async (request: FastifyRequest) => {
|
||||
const auth = gameAuth(request); await store.get(auth);
|
||||
return auth;
|
||||
};
|
||||
const available = async <T>(request: FastifyRequest, work: (auth: GameSessionAuth) => Promise<T>) => {
|
||||
const auth = gameAuth(request), state = await store.get(auth);
|
||||
const reserve = async () => {
|
||||
if ((await store.get(auth)).activeMatch) throw new ApiError(409, 'MATCH_PENDING');
|
||||
return work(auth);
|
||||
};
|
||||
return state.playerId && ranked ? ranked.withAvailable(state.playerId, reserve) : reserve();
|
||||
};
|
||||
gameRoute(app, 'POST', '/api/rooms', async request => {
|
||||
return available(request, auth => rooms.create(auth.gameSessionId, (request.body as any)?.requestId));
|
||||
});
|
||||
gameRoute(app, 'POST', '/api/rooms/join', async request => {
|
||||
const room = await available(request, auth => rooms.join(auth.gameSessionId, String((request.body as any)?.code ?? '').trim().toUpperCase()));
|
||||
await signaling.notifyRoom(room.id); return room;
|
||||
});
|
||||
gameRoute(app, 'GET', '/api/rooms/:id', async request => {
|
||||
const auth = await participant(request); return rooms.get((request.params as any).id, auth.gameSessionId);
|
||||
});
|
||||
gameRoute(app, 'POST', '/api/rooms/:id/rematch', async request => {
|
||||
const room = await available(request, auth => rooms.rematch((request.params as any).id, auth.gameSessionId, (request.body as any)?.round));
|
||||
await signaling.notifyRoom(room.id); return room;
|
||||
});
|
||||
gameRoute(app, 'POST', '/api/rooms/:id/leave', async request => {
|
||||
const auth = await participant(request);
|
||||
const room = await rooms.leave((request.params as any).id, auth.gameSessionId);
|
||||
await signaling.notifyRoom(room.id); return room;
|
||||
});
|
||||
gameRoute(app, 'GET', '/api/rtc-config', async request => signaling.rtcConfig(await participant(request), (request.query as any).roomId));
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { MongoServerError, type Db } from 'mongodb';
|
||||
import { GAME_SESSION_TTL_MS } from '../../constants/recording.js';
|
||||
import { emptyPlayerStats, type ActiveMatch, type GameSessionAuth, type GameSessionState, type RecordAck, type ResultSubmission } from '../shared/p2p-types.js';
|
||||
import type { PlayerStats } from '../shared/types.js';
|
||||
import { normalizeAppearance } from '../shared/cosmetics.js';
|
||||
import type { FighterAppearance } from '../shared/cosmetics.js';
|
||||
import type { Pair } from '../shared/types.js';
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(public statusCode: number, public code: string) { super(code); }
|
||||
}
|
||||
export const validId = (value: unknown): value is string => typeof value === 'string' && /^[A-Za-z0-9_-]{1,80}$/.test(value);
|
||||
const hash = (value: string) => createHash('sha256').update(value).digest('hex');
|
||||
interface SessionDocument {
|
||||
_id: string; tokenHash: string; playerId?: string; environment: string; createdAt: Date; updatedAt: Date; expiresAt: Date;
|
||||
stats: PlayerStats; lastAppliedSeq: number;
|
||||
activeMatch?: ActiveMatch & { registerRequestId: string };
|
||||
lastResult?: { matchId: string; seq: number; fingerprint: string; outcome: string; appliedAt: Date };
|
||||
}
|
||||
export interface RegisterMatch extends Omit<ActiveMatch, 'seq'> { requestId: string; expectedPreviousSeq: number }
|
||||
export async function createGameSessionStore(db: Db, now: () => number = Date.now, environment = 'production') {
|
||||
const sessions = db.collection<SessionDocument>('game_sessions');
|
||||
await sessions.createIndex({ expiresAt: 1 }, { expireAfterSeconds: 0 });
|
||||
await sessions.createIndex({ playerId: 1, updatedAt: -1 });
|
||||
const authFilter = (auth: GameSessionAuth) => {
|
||||
if (!auth || !validId(auth.gameSessionId) || typeof auth.token !== 'string' || !/^[A-Za-z0-9_-]{43}$/.test(auth.token)) throw new ApiError(401, 'INVALID_SESSION');
|
||||
return { _id: auth.gameSessionId, tokenHash: hash(auth.token), expiresAt: { $gt: new Date(now()) } };
|
||||
};
|
||||
const read = async (auth: GameSessionAuth) => {
|
||||
const doc = await sessions.findOne(authFilter(auth));
|
||||
if (!doc) throw new ApiError(401, 'INVALID_SESSION');
|
||||
return doc;
|
||||
};
|
||||
const active = (doc: SessionDocument): ActiveMatch | undefined => {
|
||||
if (!doc.activeMatch) return undefined;
|
||||
const { registerRequestId: _, ...match } = doc.activeMatch;
|
||||
return match;
|
||||
};
|
||||
const publicState = (doc: SessionDocument, auth: GameSessionAuth): GameSessionState => ({
|
||||
gameSessionId: auth.gameSessionId, token: auth.token, stats: doc.stats, lastAppliedSeq: doc.lastAppliedSeq,
|
||||
...(doc.playerId ? { playerId: doc.playerId } : {}),
|
||||
...(doc.activeMatch ? { activeMatch: active(doc) } : {}),
|
||||
});
|
||||
return {
|
||||
async create(createRequestId: string, playerId?: string): Promise<GameSessionState> {
|
||||
// This creation key is a 256-bit bearer secret, persisted by the tab before the request.
|
||||
// Domain separation makes an interrupted creation retry return the same identity and token.
|
||||
if (typeof createRequestId !== 'string' || !/^[A-Za-z0-9_-]{43}$/.test(createRequestId)) throw new ApiError(400, 'INVALID_CREATION_KEY');
|
||||
const gameSessionId = hash(`session:${createRequestId}`);
|
||||
const token = createHash('sha256').update(`token:${createRequestId}`).digest('base64url');
|
||||
const at = new Date(now());
|
||||
try {
|
||||
await sessions.updateOne({ _id: gameSessionId }, { $setOnInsert: {
|
||||
tokenHash: hash(token), ...(playerId ? { playerId } : {}), environment, createdAt: at, updatedAt: at,
|
||||
expiresAt: new Date(now() + GAME_SESSION_TTL_MS), stats: emptyPlayerStats(), lastAppliedSeq: 0,
|
||||
} }, { upsert: true });
|
||||
} catch (error) { if (!(error instanceof MongoServerError && error.code === 11000)) throw error; }
|
||||
return publicState(await read({ gameSessionId, token }), { gameSessionId, token });
|
||||
},
|
||||
async get(auth: GameSessionAuth) { return publicState(await read(auth), auth); },
|
||||
async register(auth: GameSessionAuth, request: RegisterMatch): Promise<ActiveMatch> {
|
||||
if (!request || !validId(request.requestId) || !validId(request.matchId) || !['bot', 'friend'].includes(request.mode) || !Number.isSafeInteger(request.expectedPreviousSeq) || request.expectedPreviousSeq < 0) throw new ApiError(400, 'INVALID_REQUEST');
|
||||
const before = await read(auth);
|
||||
const sameRequest = before.activeMatch?.registerRequestId === request.requestId;
|
||||
if (sameRequest) {
|
||||
const m = before.activeMatch!;
|
||||
if (m.matchId !== request.matchId || m.mode !== request.mode || m.roomId !== request.roomId || m.seq !== request.expectedPreviousSeq + 1) throw new ApiError(409, 'REQUEST_CONFLICT');
|
||||
return active(before)!;
|
||||
}
|
||||
const profile = before.playerId ? await db.collection<any>('player_profiles').findOne({ _id: before.playerId }) : null;
|
||||
const ownAppearance = normalizeAppearance(profile?.appearance);
|
||||
let appearances: Pair<FighterAppearance> = [ownAppearance, { characterId: ownAppearance.characterId === 0 ? 1 : 0, colorId: 'white' }];
|
||||
if (request.mode === 'friend') {
|
||||
if (!validId(request.roomId)) throw new ApiError(400, 'INVALID_ROOM');
|
||||
const room = await db.collection<any>('rooms').findOne({ _id: request.roomId, currentMatchId: request.matchId,
|
||||
status: 'paired', expiresAt: { $gt: new Date(now()) },
|
||||
$or: [{ hostGameSessionId: auth.gameSessionId }, { guestGameSessionId: auth.gameSessionId }] });
|
||||
if (!room) throw new ApiError(404, 'ROOM_NOT_FOUND');
|
||||
appearances = room.appearances ?? [normalizeAppearance(undefined), { characterId: 1, colorId: 'white' }];
|
||||
} else if (request.roomId !== undefined) throw new ApiError(400, 'INVALID_ROOM');
|
||||
const match = { matchId: request.matchId, seq: request.expectedPreviousSeq + 1, mode: request.mode,
|
||||
...(request.roomId ? { roomId: request.roomId } : {}), appearances, registerRequestId: request.requestId };
|
||||
const doc = await sessions.findOneAndUpdate({ ...authFilter(auth), lastAppliedSeq: request.expectedPreviousSeq, activeMatch: { $exists: false } }, {
|
||||
$set: { activeMatch: match, updatedAt: new Date(now()), expiresAt: new Date(now() + GAME_SESSION_TTL_MS) },
|
||||
}, { returnDocument: 'after' });
|
||||
if (doc) return active(doc)!;
|
||||
const current = await read(auth);
|
||||
if (current.activeMatch?.registerRequestId === request.requestId && current.activeMatch.matchId === match.matchId && current.activeMatch.seq === match.seq && current.activeMatch.mode === match.mode && current.activeMatch.roomId === match.roomId) return active(current)!;
|
||||
throw new ApiError(409, 'MATCH_PENDING');
|
||||
},
|
||||
async record(auth: GameSessionAuth, input: ResultSubmission): Promise<RecordAck> {
|
||||
if (!input || !validId(input.matchId) || !Number.isSafeInteger(input.seq) || input.seq < 1 || !['bot', 'friend'].includes(input.mode) || !['win', 'loss', 'draw', 'aborted'].includes(input.outcome) || !['hit', 'timeout', 'double_timeout', 'forfeit', 'disconnect', 'double_disconnect', 'connection_failed', 'protocol_error'].includes(input.reason)) throw new ApiError(400, 'INVALID_RESULT');
|
||||
const fingerprint = hash(JSON.stringify([input.matchId, input.seq, input.mode, input.outcome, input.reason]));
|
||||
const prefix = `stats.${input.mode}`, at = new Date(now());
|
||||
const ref = (field: string) => `$${prefix}.${field}`;
|
||||
const changes: Record<string, unknown> = {
|
||||
lastAppliedSeq: input.seq, updatedAt: at, expiresAt: new Date(now() + GAME_SESSION_TTL_MS),
|
||||
lastResult: { $literal: { matchId: input.matchId, seq: input.seq, fingerprint, outcome: input.outcome, appliedAt: at } },
|
||||
};
|
||||
if (input.outcome === 'win') {
|
||||
changes[`${prefix}.wins`] = { $add: [ref('wins'), 1] };
|
||||
changes[`${prefix}.current`] = { $add: [ref('current'), 1] };
|
||||
changes[`${prefix}.best`] = { $max: [ref('best'), { $add: [ref('current'), 1] }] };
|
||||
} else if (input.outcome === 'loss') {
|
||||
changes[`${prefix}.losses`] = { $add: [ref('losses'), 1] }; changes[`${prefix}.current`] = 0;
|
||||
} else if (input.outcome === 'draw') changes[`${prefix}.draws`] = { $add: [ref('draws'), 1] };
|
||||
const doc = await sessions.findOneAndUpdate({ ...authFilter(auth), lastAppliedSeq: input.seq - 1,
|
||||
'activeMatch.matchId': input.matchId, 'activeMatch.seq': input.seq, 'activeMatch.mode': input.mode,
|
||||
}, [{ $set: changes }, { $unset: 'activeMatch' }], { returnDocument: 'after' });
|
||||
if (doc) return { status: 'applied', stats: doc.stats };
|
||||
const current = await read(auth);
|
||||
if (input.seq < current.lastAppliedSeq) return { status: 'stale', stats: current.stats };
|
||||
if (input.seq === current.lastAppliedSeq && current.lastResult?.matchId === input.matchId) {
|
||||
if (current.lastResult.fingerprint !== fingerprint) throw new ApiError(409, 'RESULT_CONFLICT');
|
||||
return { status: 'duplicate', stats: current.stats };
|
||||
}
|
||||
throw new ApiError(409, 'NO_ACTIVE_MATCH');
|
||||
},
|
||||
};
|
||||
}
|
||||
export type GameSessionStore = Awaited<ReturnType<typeof createGameSessionStore>>;
|
||||
@@ -0,0 +1,10 @@
|
||||
import { resolve } from 'node:path';
|
||||
import { createGameServer } from './create-server.js';
|
||||
const { MONGODB_URI, MONGODB_DATABASE = 'dragon_ball', HOST = '0.0.0.0', PORT = '3000', ADMIN_USER, ADMIN_PASSWORD } = process.env;
|
||||
if (!MONGODB_URI) throw new Error('MONGODB_URI 환경 변수가 필요합니다.');
|
||||
const urls = (value: string | undefined) => value?.split(',').map(s => s.trim()).filter(Boolean);
|
||||
const app = await createGameServer({ mongoUri: MONGODB_URI, databaseName: MONGODB_DATABASE, clientDirectory: resolve('dist/client'), adminUser: ADMIN_USER, adminPassword: ADMIN_PASSWORD,
|
||||
rtc: { stunUrls: urls(process.env.STUN_URLS), turnUrls: urls(process.env.TURN_URLS), turnSecret: process.env.TURN_SHARED_SECRET } });
|
||||
await app.listen({ host: HOST, port: Number(PORT) });
|
||||
console.log(`http://${HOST}:${PORT}`);
|
||||
for (const signal of ['SIGINT', 'SIGTERM'] as const) process.once(signal, () => { void app.close(); });
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { FastifyInstance, FastifyRequest } from 'fastify';
|
||||
import { gameAuth, gameRoute } from './game-api.js';
|
||||
import { ApiError, type GameSessionStore } from './game-session-store.js';
|
||||
import type { RankedService } from './ranked-service.js';
|
||||
import type { FighterAppearance } from '../shared/cosmetics.js';
|
||||
import type { ActionId } from '../shared/types.js';
|
||||
|
||||
export function registerRankedApi(app: FastifyInstance, sessions: GameSessionStore, service: RankedService) {
|
||||
const identity = async (request: FastifyRequest) => {
|
||||
const session = await sessions.get(gameAuth(request));
|
||||
if (!session.playerId) throw new ApiError(401, 'INVALID_SESSION');
|
||||
return { playerId: session.playerId, gameSessionId: session.gameSessionId };
|
||||
};
|
||||
gameRoute(app, 'GET', '/api/profile', async request => service.profile((await identity(request)).playerId));
|
||||
gameRoute(app, 'POST', '/api/profile', async request => {
|
||||
const { playerId } = await identity(request);
|
||||
return service.updateProfile(playerId, request.body as { appearance?: FighterAppearance });
|
||||
});
|
||||
gameRoute(app, 'GET', '/api/ranked/leaderboard', async request => service.leaderboard((await identity(request)).playerId));
|
||||
for (const [url, method] of [['status', 'GET'], ['queue', 'POST'], ['cancel', 'POST']] as const) {
|
||||
gameRoute(app, method, `/api/ranked/${url}`, async request => {
|
||||
const { playerId, gameSessionId } = await identity(request);
|
||||
return service[url === 'queue' ? 'join' : url](playerId, gameSessionId);
|
||||
});
|
||||
}
|
||||
gameRoute(app, 'POST', '/api/ranked/action', async request => {
|
||||
const { playerId, gameSessionId } = await identity(request);
|
||||
const body = request.body as { matchId: string; turnId: number; action: ActionId } | undefined;
|
||||
if (!body) throw new ApiError(400, 'INVALID_REQUEST');
|
||||
return service.action(playerId, gameSessionId, body.matchId, body.turnId, body.action);
|
||||
});
|
||||
gameRoute(app, 'POST', '/api/ranked/leave', async request => {
|
||||
const { playerId, gameSessionId } = await identity(request);
|
||||
const body = request.body as { matchId: string } | undefined;
|
||||
if (!body) throw new ApiError(400, 'INVALID_REQUEST');
|
||||
return service.leave(playerId, gameSessionId, body.matchId);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
import { randomInt, randomUUID } from 'node:crypto';
|
||||
import type { Db } from 'mongodb';
|
||||
import { COSMETIC_COLORS, EQUIPMENT_IDS, EYEWEAR_IDS, HAIR_STYLES, SKIN_TONES } from '../../constants/cosmetics.js';
|
||||
import {
|
||||
PROFILE_NAME_NOUNS, PROFILE_NAME_PREFIXES, PROFILE_NAME_SUFFIX_MAX, PROFILE_NAME_SUFFIX_MIN, PROFILE_NAME_VERSION,
|
||||
RANKED_FINISHED_TTL_MS, RANKED_LEADERBOARD_LIMIT, RANKED_QUEUE_TTL_MS, RANKED_SAVE_RETRY_MS,
|
||||
} from '../../constants/ranked.js';
|
||||
import { availableColorIds, availableEquipmentIds, DEFAULT_APPEARANCE, normalizeAppearance, type FighterAppearance } from '../shared/cosmetics.js';
|
||||
import { createMatchEngine, type MatchEngine } from '../shared/match-engine.js';
|
||||
import { emptyPlayerStats, type LocalEndReason } from '../shared/p2p-types.js';
|
||||
import type { Leaderboard, PlayerProfile, RankedStatus } from '../shared/ranked-types.js';
|
||||
import { isLegalAction } from '../shared/rules.js';
|
||||
import type { ActionId, ModeStats, Pair } from '../shared/types.js';
|
||||
import { systemClock, type Clock } from './clock.js';
|
||||
import { ApiError, validId } from './game-session-store.js';
|
||||
|
||||
interface ProfileDocument {
|
||||
_id: string; displayName: string; nameVersion?: number; appearance: FighterAppearance; environment: string; createdAt: Date; updatedAt: Date;
|
||||
}
|
||||
interface TerminalMatch {
|
||||
_id: string; playerIds: Pair<string>; appearances: Pair<FighterAppearance>; winnerIndex: 0 | 1 | null;
|
||||
reason: LocalEndReason; order: number; completedAt: Date; environment: string;
|
||||
}
|
||||
interface QueueEntry { playerId: string; gameSessionId: string; expiresAt: number }
|
||||
interface RankedMatch {
|
||||
id: string; playerIds: Pair<string>; appearances: Pair<FighterAppearance>; engine: MatchEngine;
|
||||
actions: Pair<ActionId | null>; departed: Set<string>; timer?: ReturnType<Clock['setTimeout']>;
|
||||
terminal?: TerminalMatch; saved: boolean; finishedExpiresAt?: Pair<number>;
|
||||
}
|
||||
const emptyStats = (): ModeStats => ({ wins: 0, losses: 0, draws: 0, current: 0, best: 0 });
|
||||
function generatedName(): string {
|
||||
return `${PROFILE_NAME_PREFIXES[randomInt(PROFILE_NAME_PREFIXES.length)]}${PROFILE_NAME_NOUNS[randomInt(PROFILE_NAME_NOUNS.length)]}-${randomInt(PROFILE_NAME_SUFFIX_MIN, PROFILE_NAME_SUFFIX_MAX)}`;
|
||||
}
|
||||
|
||||
// One service owns the queue and all match clocks in the established single-process server.
|
||||
export async function createRankedService(db: Db, clock: Clock = systemClock, environment = 'production') {
|
||||
const profiles = db.collection<ProfileDocument>('player_profiles');
|
||||
const results = db.collection<TerminalMatch>('ranked_matches');
|
||||
// Finish migrating every legacy name before any profile or leaderboard can be served.
|
||||
// The version guard makes interrupted migrations/restarts preserve assigned names.
|
||||
for await (const profile of profiles.find({ nameVersion: { $ne: PROFILE_NAME_VERSION } })) {
|
||||
await profiles.updateOne({ _id: profile._id, nameVersion: { $ne: PROFILE_NAME_VERSION } }, { $set: {
|
||||
displayName: generatedName(), nameVersion: PROFILE_NAME_VERSION, updatedAt: new Date(clock.now()),
|
||||
} });
|
||||
}
|
||||
await results.createIndex({ playerIds: 1, order: 1 });
|
||||
let resultOrder = (await results.find().sort({ order: -1 }).limit(1).next())?.order ?? 0;
|
||||
const queue = new Map<string, QueueEntry>();
|
||||
const byPlayer = new Map<string, RankedMatch>();
|
||||
const matches = new Set<RankedMatch>();
|
||||
let tail: Promise<unknown> = Promise.resolve(), closed = false;
|
||||
function serial<T>(work: () => Promise<T>): Promise<T> {
|
||||
const next = tail.then(() => {
|
||||
if (closed) throw new ApiError(503, 'RANKED_CLOSED');
|
||||
expireQueue(); expireFinished();
|
||||
return work();
|
||||
});
|
||||
tail = next.catch(() => {});
|
||||
return next;
|
||||
}
|
||||
function expireQueue() {
|
||||
for (const [playerId, entry] of queue) if (entry.expiresAt <= clock.now()) queue.delete(playerId);
|
||||
}
|
||||
function expireFinished() {
|
||||
for (const match of matches) {
|
||||
if (!match.saved || !match.finishedExpiresAt) continue;
|
||||
for (const slot of [0, 1] as const) if (match.finishedExpiresAt[slot] <= clock.now()) {
|
||||
const playerId = match.playerIds[slot];
|
||||
if (byPlayer.get(playerId) === match) byPlayer.delete(playerId);
|
||||
match.departed.add(playerId);
|
||||
}
|
||||
if (match.departed.size === 2) {
|
||||
if (match.timer !== undefined) clock.clearTimeout(match.timer);
|
||||
matches.delete(match);
|
||||
}
|
||||
}
|
||||
}
|
||||
async function identity(playerId: string, gameSessionId: string) {
|
||||
if (!validId(playerId) || !validId(gameSessionId) || !await db.collection<any>('game_sessions').findOne({
|
||||
_id: gameSessionId, playerId, expiresAt: { $gt: new Date(clock.now()) },
|
||||
})) throw new ApiError(401, 'INVALID_SESSION');
|
||||
}
|
||||
async function freeForRanked(playerId: string) {
|
||||
const sessions = await db.collection<any>('game_sessions').find({ playerId, expiresAt: { $gt: new Date(clock.now()) } }).toArray();
|
||||
if (sessions.some(session => session.activeMatch) || await db.collection<any>('rooms').findOne({
|
||||
members: { $in: sessions.map(session => session._id) }, open: true, expiresAt: { $gt: new Date(clock.now()) },
|
||||
})) throw new ApiError(409, 'MATCH_PENDING');
|
||||
}
|
||||
function addResult(stats: ModeStats, result: TerminalMatch, playerId: string) {
|
||||
if (result.winnerIndex === null) stats.draws++;
|
||||
else if (result.playerIds[result.winnerIndex] === playerId) {
|
||||
stats.wins++; stats.current++; stats.best = Math.max(stats.best, stats.current);
|
||||
} else { stats.losses++; stats.current = 0; }
|
||||
}
|
||||
async function readProfile(playerId: string): Promise<PlayerProfile> {
|
||||
if (!validId(playerId)) throw new ApiError(400, 'INVALID_PROFILE');
|
||||
const now = new Date(clock.now());
|
||||
await profiles.updateOne({ _id: playerId }, { $setOnInsert: {
|
||||
displayName: generatedName(), nameVersion: PROFILE_NAME_VERSION,
|
||||
appearance: { ...DEFAULT_APPEARANCE }, environment, createdAt: now, updatedAt: now,
|
||||
} }, { upsert: true });
|
||||
const doc = (await profiles.findOne({ _id: playerId }))!;
|
||||
const stats = emptyStats();
|
||||
for await (const result of results.find({ playerIds: playerId }).sort({ order: 1, _id: 1 })) addResult(stats, result, playerId);
|
||||
return { playerId, displayName: doc.displayName, appearance: normalizeAppearance(doc.appearance), stats,
|
||||
unlockedColors: availableColorIds(stats), unlockedEquipment: availableEquipmentIds(stats) };
|
||||
}
|
||||
async function persist(match: RankedMatch) {
|
||||
if (match.saved) return;
|
||||
const state = match.engine.view();
|
||||
if (!state.finished || state.aborted) return;
|
||||
// Immutable per-match record is the only source of ranked stats/rewards. A retry after
|
||||
// an ambiguous Mongo response cannot award either player twice, even on standalone Mongo.
|
||||
match.terminal ??= { _id: match.id, playerIds: match.playerIds, appearances: match.appearances,
|
||||
winnerIndex: state.finished.winnerIndex, reason: state.finished.reason,
|
||||
order: ++resultOrder, completedAt: new Date(clock.now()), environment };
|
||||
try {
|
||||
await results.updateOne({ _id: match.id }, { $setOnInsert: match.terminal }, { upsert: true });
|
||||
match.saved = true;
|
||||
match.engine.saved(emptyPlayerStats());
|
||||
} catch (error) { match.engine.saveFailed(); throw error; }
|
||||
}
|
||||
function schedule(match: RankedMatch) {
|
||||
if (match.timer !== undefined) clock.clearTimeout(match.timer);
|
||||
match.timer = undefined;
|
||||
if (closed || !matches.has(match)) return;
|
||||
const state = match.engine.view();
|
||||
if (state.aborted) return;
|
||||
if (state.phase === 'finished' && match.saved && match.departed.size === 2) return;
|
||||
const at = state.phase === 'countdown' ? state.startsAt : state.phase === 'selecting' ? state.deadline
|
||||
: state.phase === 'revealing' ? state.reveal!.revealEndsAt : match.saved
|
||||
? Math.min(...match.playerIds.map((id, slot) => match.departed.has(id) ? Infinity : match.finishedExpiresAt![slot]))
|
||||
: clock.now() + RANKED_SAVE_RETRY_MS;
|
||||
match.timer = clock.setTimeout(() => {
|
||||
match.timer = undefined;
|
||||
// advance schedules persistence retries itself; don't leak an unhandled timer rejection.
|
||||
void serial(() => advance(match)).catch(() => {});
|
||||
}, Math.max(0, at - clock.now()));
|
||||
}
|
||||
async function advance(match: RankedMatch) {
|
||||
try {
|
||||
let state = match.engine.view();
|
||||
if (state.phase === 'countdown' && clock.now() >= state.startsAt) match.engine.beginTurn(state.startsAt);
|
||||
if (state.phase === 'revealing' && clock.now() >= state.reveal!.revealEndsAt) {
|
||||
match.actions = [null, null]; match.engine.beginTurn(state.reveal!.revealEndsAt);
|
||||
}
|
||||
state = match.engine.view();
|
||||
if (state.phase === 'selecting' && clock.now() >= state.deadline) match.engine.resolve(match.actions);
|
||||
if (match.engine.view().phase === 'finished') {
|
||||
match.finishedExpiresAt ??= [clock.now() + RANKED_FINISHED_TTL_MS, clock.now() + RANKED_FINISHED_TTL_MS];
|
||||
await persist(match);
|
||||
}
|
||||
} finally { schedule(match); }
|
||||
}
|
||||
async function statusView(playerId: string): Promise<RankedStatus> {
|
||||
const match = byPlayer.get(playerId);
|
||||
if (match) await advance(match);
|
||||
const profile = await readProfile(playerId);
|
||||
if (!match) return { status: queue.has(playerId) ? 'queued' : 'idle', profile };
|
||||
const selfIndex = match.playerIds[0] === playerId ? 0 : 1;
|
||||
const state = match.engine.view();
|
||||
if (state.finished) {
|
||||
state.finished.stats.friend = structuredClone(profile.stats);
|
||||
match.finishedExpiresAt![selfIndex] = clock.now() + RANKED_FINISHED_TTL_MS;
|
||||
schedule(match);
|
||||
}
|
||||
state.selfIndex = selfIndex; state.ownAction = match.actions[selfIndex];
|
||||
// Own submissions change this participant's version without revealing whether the
|
||||
// opponent has locked a choice. Engine transitions always move past either value.
|
||||
state.version = state.version * 2 + (state.ownAction === null ? 0 : 1);
|
||||
state.ranked = true; state.appearances = structuredClone(match.appearances);
|
||||
state.opponentLeft = match.departed.has(match.playerIds[1 - selfIndex]);
|
||||
state.opponentPresent = !state.opponentLeft;
|
||||
return { status: 'matched', profile, match: state };
|
||||
}
|
||||
function memberMatch(playerId: string, matchId: string) {
|
||||
if (!validId(matchId)) throw new ApiError(400, 'INVALID_REQUEST');
|
||||
const match = byPlayer.get(playerId);
|
||||
if (!match || match.id !== matchId) throw new ApiError(409, 'NO_ACTIVE_MATCH');
|
||||
return match;
|
||||
}
|
||||
return {
|
||||
async profile(playerId: string) {
|
||||
if (closed) throw new ApiError(503, 'RANKED_CLOSED');
|
||||
return readProfile(playerId);
|
||||
},
|
||||
updateProfile(playerId: string, input: { appearance?: FighterAppearance }): Promise<PlayerProfile> {
|
||||
return serial(async () => {
|
||||
if (!input || typeof input !== 'object' || Array.isArray(input)) throw new ApiError(400, 'INVALID_PROFILE');
|
||||
if (Object.hasOwn(input, 'displayName')) throw new ApiError(403, 'PROFILE_NAME_READONLY');
|
||||
const profile = await readProfile(playerId);
|
||||
const update: Partial<ProfileDocument> = {};
|
||||
if (input.appearance !== undefined) {
|
||||
const appearance = input.appearance;
|
||||
if (!appearance || typeof appearance !== 'object' || Array.isArray(appearance)
|
||||
|| (appearance.characterId !== 0 && appearance.characterId !== 1)
|
||||
|| typeof appearance.colorId !== 'string' || !COSMETIC_COLORS.some(color => color.id === appearance.colorId)) throw new ApiError(400, 'INVALID_APPEARANCE');
|
||||
if (Object.hasOwn(appearance, 'hairStyle') && appearance.hairStyle !== 'default'
|
||||
&& !HAIR_STYLES.some(hair => hair.id === appearance.hairStyle)) throw new ApiError(400, 'INVALID_APPEARANCE');
|
||||
if (Object.hasOwn(appearance, 'skinTone') && !SKIN_TONES.some(tone => tone.id === appearance.skinTone)) throw new ApiError(400, 'INVALID_APPEARANCE');
|
||||
if (Object.hasOwn(appearance, 'equipment') && (!Array.isArray(appearance.equipment)
|
||||
|| new Set(appearance.equipment).size !== appearance.equipment.length
|
||||
|| [...appearance.equipment].some(id => !EQUIPMENT_IDS.includes(id)))) throw new ApiError(400, 'INVALID_APPEARANCE');
|
||||
if (EYEWEAR_IDS.filter(id => appearance.equipment?.includes(id)).length > 1) throw new ApiError(400, 'INVALID_APPEARANCE');
|
||||
if (!availableColorIds(profile.stats).includes(appearance.colorId)) throw new ApiError(403, 'COLOR_LOCKED');
|
||||
// Older clients only send character/color; keep their current skin, hair and equipment.
|
||||
const next = normalizeAppearance({ ...profile.appearance, ...appearance });
|
||||
const unlockedEquipment = availableEquipmentIds(profile.stats);
|
||||
if (next.equipment!.some(id => !unlockedEquipment.includes(id))) throw new ApiError(403, 'EQUIPMENT_LOCKED');
|
||||
update.appearance = next;
|
||||
}
|
||||
if (Object.keys(update).length) await profiles.updateOne({ _id: playerId }, { $set: { ...update, updatedAt: new Date(clock.now()) } });
|
||||
return readProfile(playerId);
|
||||
});
|
||||
},
|
||||
async leaderboard(playerId: string): Promise<Leaderboard> {
|
||||
if (closed) throw new ApiError(503, 'RANKED_CLOSED');
|
||||
await readProfile(playerId);
|
||||
const totals = new Map<string, ModeStats>();
|
||||
for await (const result of results.find().sort({ order: 1, _id: 1 })) for (const id of result.playerIds) {
|
||||
const stats = totals.get(id) ?? emptyStats(); addResult(stats, result, id); totals.set(id, stats);
|
||||
}
|
||||
const entries = (await profiles.find({ _id: { $in: [...totals.keys()] } }).toArray()).map(profile => ({ playerId: profile._id,
|
||||
displayName: profile.displayName, wins: totals.get(profile._id)?.wins ?? 0, best: totals.get(profile._id)?.best ?? 0,
|
||||
})).sort((a, b) => b.best - a.best || b.wins - a.wins || (a.playerId < b.playerId ? -1 : a.playerId > b.playerId ? 1 : 0))
|
||||
.map((entry, index) => ({ rank: index + 1, ...entry }));
|
||||
return { entries: entries.slice(0, RANKED_LEADERBOARD_LIMIT), self: entries.find(entry => entry.playerId === playerId) };
|
||||
},
|
||||
assertAvailable(playerId: string): Promise<void> {
|
||||
return serial(async () => { expireQueue(); if (queue.has(playerId) || byPlayer.has(playerId)) throw new ApiError(409, 'MATCH_PENDING'); });
|
||||
},
|
||||
withAvailable<T>(playerId: string, work: () => Promise<T>): Promise<T> {
|
||||
return serial(async () => {
|
||||
if (queue.has(playerId) || byPlayer.has(playerId)) throw new ApiError(409, 'MATCH_PENDING');
|
||||
return work();
|
||||
});
|
||||
},
|
||||
status(playerId: string, gameSessionId: string): Promise<RankedStatus> {
|
||||
return serial(async () => {
|
||||
await identity(playerId, gameSessionId); expireQueue();
|
||||
const entry = queue.get(playerId);
|
||||
if (entry) { entry.gameSessionId = gameSessionId; entry.expiresAt = clock.now() + RANKED_QUEUE_TTL_MS; }
|
||||
return statusView(playerId);
|
||||
});
|
||||
},
|
||||
join(playerId: string, gameSessionId: string): Promise<RankedStatus> {
|
||||
return serial(async () => {
|
||||
await identity(playerId, gameSessionId); expireQueue();
|
||||
if (byPlayer.has(playerId)) return statusView(playerId);
|
||||
await freeForRanked(playerId); await readProfile(playerId);
|
||||
queue.set(playerId, { playerId, gameSessionId, expiresAt: clock.now() + RANKED_QUEUE_TTL_MS });
|
||||
for (const candidate of queue.values()) {
|
||||
if (candidate.playerId === playerId) continue;
|
||||
try { await identity(candidate.playerId, candidate.gameSessionId); await freeForRanked(candidate.playerId); }
|
||||
catch (error) {
|
||||
if (!(error instanceof ApiError)) throw error;
|
||||
queue.delete(candidate.playerId); continue;
|
||||
}
|
||||
const first = await readProfile(candidate.playerId), second = await readProfile(playerId);
|
||||
const id = randomUUID(), engine = createMatchEngine({ matchId: id, mode: 'friend', selfIndex: 0, clock });
|
||||
const match: RankedMatch = { id, engine, playerIds: [candidate.playerId, playerId],
|
||||
appearances: [first.appearance, second.appearance], actions: [null, null], departed: new Set(), saved: false };
|
||||
engine.start(); matches.add(match);
|
||||
for (const id of match.playerIds) { queue.delete(id); byPlayer.set(id, match); }
|
||||
schedule(match); break;
|
||||
}
|
||||
return statusView(playerId);
|
||||
});
|
||||
},
|
||||
cancel(playerId: string, gameSessionId: string): Promise<RankedStatus> {
|
||||
return serial(async () => {
|
||||
await identity(playerId, gameSessionId); expireQueue();
|
||||
if (byPlayer.has(playerId)) throw new ApiError(409, 'MATCH_PENDING');
|
||||
queue.delete(playerId); return statusView(playerId);
|
||||
});
|
||||
},
|
||||
action(playerId: string, gameSessionId: string, matchId: string, turnId: number, action: ActionId): Promise<RankedStatus> {
|
||||
return serial(async () => {
|
||||
await identity(playerId, gameSessionId);
|
||||
const match = memberMatch(playerId, matchId); await advance(match);
|
||||
const state = match.engine.view(), slot = match.playerIds[0] === playerId ? 0 : 1;
|
||||
if (!Number.isSafeInteger(turnId) || state.phase !== 'selecting' || turnId !== state.turnId
|
||||
|| clock.now() < state.startsAt || clock.now() >= state.deadline || !isLegalAction(action, state.energy[slot])) throw new ApiError(400, 'INVALID_ACTION');
|
||||
if (match.actions[slot] !== null) throw new ApiError(409, 'ACTION_LOCKED');
|
||||
match.actions[slot] = action;
|
||||
return statusView(playerId);
|
||||
});
|
||||
},
|
||||
leave(playerId: string, gameSessionId: string, matchId: string): Promise<RankedStatus> {
|
||||
return serial(async () => {
|
||||
await identity(playerId, gameSessionId);
|
||||
if (!validId(matchId)) throw new ApiError(400, 'INVALID_REQUEST');
|
||||
if (!byPlayer.has(playerId) && await results.findOne({ _id: matchId, playerIds: playerId })) return statusView(playerId);
|
||||
const match = memberMatch(playerId, matchId); await advance(match);
|
||||
if (match.engine.view().phase !== 'finished') {
|
||||
match.engine.finish(match.playerIds[0] === playerId ? 1 : 0, 'forfeit');
|
||||
await advance(match);
|
||||
}
|
||||
match.departed.add(playerId); byPlayer.delete(playerId);
|
||||
if (match.departed.size === 2) matches.delete(match);
|
||||
schedule(match);
|
||||
return statusView(playerId);
|
||||
});
|
||||
},
|
||||
async close(): Promise<void> {
|
||||
if (closed) return;
|
||||
await serial(async () => {
|
||||
closed = true; queue.clear();
|
||||
for (const match of matches) {
|
||||
if (match.timer !== undefined) clock.clearTimeout(match.timer);
|
||||
// Unfinished matches are deliberately not recovered or awarded after shutdown.
|
||||
if (match.engine.view().phase !== 'finished') match.engine.abort('connection_failed');
|
||||
}
|
||||
byPlayer.clear(); matches.clear();
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
export type RankedService = Awaited<ReturnType<typeof createRankedService>>;
|
||||
@@ -0,0 +1,105 @@
|
||||
import { randomInt, randomUUID } from 'node:crypto';
|
||||
import { ROOM_CODE_ALPHABET, ROOM_CODE_LENGTH, WAITING_ROOM_TTL_MS } from '../../constants/session.js';
|
||||
import { MongoServerError, type Db } from 'mongodb';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { ACTIVE_ROOM_TTL_MS } from '../../constants/webrtc.js';
|
||||
import { ApiError, validId } from './game-session-store.js';
|
||||
import type { PeerRoom } from '../shared/p2p-types.js';
|
||||
import { normalizeAppearance } from '../shared/cosmetics.js';
|
||||
import type { FighterAppearance } from '../shared/cosmetics.js';
|
||||
import type { Pair } from '../shared/types.js';
|
||||
interface RoomDocument {
|
||||
_id: string; code: string; hostGameSessionId: string; guestGameSessionId?: string;
|
||||
members: string[]; open: boolean; status: 'waiting' | 'paired' | 'closed'; currentMatchId: string;
|
||||
round: number; rematchReady: string[]; createdAt: Date; expiresAt: Date;
|
||||
appearances?: Pair<FighterAppearance>;
|
||||
}
|
||||
export async function createRoomStore(db: Db, now: () => number = Date.now) {
|
||||
const rooms = db.collection<RoomDocument>('rooms');
|
||||
await rooms.createIndex({ code: 1 }, { unique: true });
|
||||
await rooms.createIndex({ expiresAt: 1 }, { expireAfterSeconds: 0 });
|
||||
await rooms.createIndex({ members: 1 }, { unique: true, partialFilterExpression: { open: true } });
|
||||
const releaseExpired = async (sessionId: string) => {
|
||||
await rooms.updateMany({ members: sessionId, open: true, expiresAt: { $lte: new Date(now()) } }, { $set: { open: false, status: 'closed' } });
|
||||
};
|
||||
function view(doc: RoomDocument, sessionId: string): PeerRoom {
|
||||
return { id: doc._id, code: doc.code, currentMatchId: doc.currentMatchId, round: doc.round,
|
||||
selfIndex: doc.hostGameSessionId === sessionId ? 0 : 1, count: doc.members.length, status: doc.status,
|
||||
expiresAt: +doc.expiresAt, appearances: doc.appearances, rematch: [doc.rematchReady.includes(doc.hostGameSessionId), !!doc.guestGameSessionId && doc.rematchReady.includes(doc.guestGameSessionId)] };
|
||||
}
|
||||
async function read(id: string, sessionId: string) {
|
||||
if (!validId(id) || !validId(sessionId)) throw new ApiError(400, 'INVALID_ROOM');
|
||||
const doc = await rooms.findOne({ _id: id, members: sessionId });
|
||||
if (!doc) throw new ApiError(404, 'ROOM_NOT_FOUND');
|
||||
if (+doc.expiresAt <= now()) throw new ApiError(410, 'ROOM_EXPIRED');
|
||||
return doc;
|
||||
}
|
||||
async function appearance(sessionId: string) {
|
||||
const session = await db.collection<any>('game_sessions').findOne({ _id: sessionId });
|
||||
const profile = session?.playerId ? await db.collection<any>('player_profiles').findOne({ _id: session.playerId }) : null;
|
||||
return normalizeAppearance(profile?.appearance);
|
||||
}
|
||||
return {
|
||||
async create(sessionId: string, requestId: string): Promise<PeerRoom> {
|
||||
if (!validId(sessionId) || !validId(requestId)) throw new ApiError(400, 'INVALID_REQUEST');
|
||||
const id = createHash('sha256').update(`${sessionId}:${requestId}`).digest('hex');
|
||||
const existing = await rooms.findOne({ _id: id });
|
||||
if (existing) return view(await read(id, sessionId), sessionId);
|
||||
await releaseExpired(sessionId);
|
||||
for (let attempt = 0; attempt < 5; attempt++) {
|
||||
const doc: RoomDocument = { _id: id, code: Array.from({ length: ROOM_CODE_LENGTH }, () => ROOM_CODE_ALPHABET[randomInt(ROOM_CODE_ALPHABET.length)]).join(''),
|
||||
hostGameSessionId: sessionId, members: [sessionId], open: true, status: 'waiting', currentMatchId: randomUUID(),
|
||||
round: 1, rematchReady: [], appearances: [await appearance(sessionId), { characterId: 1, colorId: 'white' }], createdAt: new Date(now()), expiresAt: new Date(now() + WAITING_ROOM_TTL_MS) };
|
||||
try { await rooms.insertOne(doc); return view(doc, sessionId); }
|
||||
catch (error) {
|
||||
if (!(error instanceof MongoServerError && error.code === 11000)) throw error;
|
||||
const duplicate = await rooms.findOne({ _id: id }); if (duplicate) return view(duplicate, sessionId);
|
||||
if (await rooms.findOne({ members: sessionId, open: true })) throw new ApiError(409, 'PLAYER_BUSY');
|
||||
}
|
||||
}
|
||||
throw new ApiError(503, 'ROOM_CODE_BUSY');
|
||||
},
|
||||
async join(sessionId: string, code: string): Promise<PeerRoom> {
|
||||
if (!validId(sessionId) || typeof code !== 'string' || !/^[A-Z0-9]{6}$/.test(code)) throw new ApiError(404, 'ROOM_NOT_FOUND');
|
||||
const before = await rooms.findOne({ code });
|
||||
if (!before) throw new ApiError(404, 'ROOM_NOT_FOUND');
|
||||
if (+before.expiresAt <= now()) throw new ApiError(410, 'ROOM_EXPIRED');
|
||||
if (before.hostGameSessionId === sessionId) throw new ApiError(409, 'SELF_JOIN');
|
||||
if (before.guestGameSessionId === sessionId && before.status === 'paired') return view(before, sessionId);
|
||||
await releaseExpired(sessionId);
|
||||
const guestAppearance = await appearance(sessionId);
|
||||
try {
|
||||
const doc = await rooms.findOneAndUpdate({ _id: before._id, open: true, status: 'waiting', guestGameSessionId: { $exists: false }, expiresAt: { $gt: new Date(now()) } }, {
|
||||
$set: { guestGameSessionId: sessionId, status: 'paired', appearances: [normalizeAppearance(before.appearances?.[0]), guestAppearance], expiresAt: new Date(now() + ACTIVE_ROOM_TTL_MS) }, $addToSet: { members: sessionId },
|
||||
}, { returnDocument: 'after' });
|
||||
if (doc) return view(doc, sessionId);
|
||||
} catch (error) {
|
||||
if (error instanceof MongoServerError && error.code === 11000) throw new ApiError(409, 'PLAYER_BUSY');
|
||||
throw error;
|
||||
}
|
||||
const current = await rooms.findOne({ _id: before._id });
|
||||
if (current?.guestGameSessionId === sessionId && current.status === 'paired') return view(current, sessionId);
|
||||
throw new ApiError(409, 'ROOM_FULL');
|
||||
},
|
||||
async get(id: string, sessionId: string) { return view(await read(id, sessionId), sessionId); },
|
||||
async rematch(id: string, sessionId: string, round: number): Promise<PeerRoom> {
|
||||
const before = await read(id, sessionId);
|
||||
if (!Number.isSafeInteger(round) || round < 1) throw new ApiError(400, 'INVALID_REQUEST');
|
||||
if (before.status !== 'paired') throw new ApiError(409, 'ROOM_CLOSED');
|
||||
if (before.round === round + 1) return view(before, sessionId);
|
||||
if (before.round !== round) throw new ApiError(409, 'STALE_ROUND');
|
||||
if (await db.collection<any>('game_sessions').findOne({ _id: { $in: before.members }, activeMatch: { $exists: true } })) throw new ApiError(409, 'MATCH_PENDING');
|
||||
await rooms.updateOne({ _id: id, round, status: 'paired' }, { $addToSet: { rematchReady: sessionId } });
|
||||
await rooms.updateOne({ _id: id, round, status: 'paired', rematchReady: { $all: before.members }, expiresAt: { $gt: new Date(now()) } }, {
|
||||
$set: { currentMatchId: randomUUID(), rematchReady: [], expiresAt: new Date(now() + ACTIVE_ROOM_TTL_MS) }, $inc: { round: 1 },
|
||||
});
|
||||
return view(await read(id, sessionId), sessionId);
|
||||
},
|
||||
async leave(id: string, sessionId: string) {
|
||||
const before = await read(id, sessionId);
|
||||
await rooms.updateOne({ _id: id, members: sessionId }, { $set: { open: false, status: 'closed', expiresAt: new Date(Math.min(+before.expiresAt, now() + 60000)) } });
|
||||
return view(await read(id, sessionId), sessionId);
|
||||
},
|
||||
};
|
||||
}
|
||||
export type RoomStore = Awaited<ReturnType<typeof createRoomStore>>;
|
||||
@@ -0,0 +1,74 @@
|
||||
import { createHmac } from 'node:crypto';
|
||||
import type { Server, Socket } from 'socket.io';
|
||||
import { CONNECT_TIMEOUT_MS, MAX_PEER_MESSAGE_BYTES, TURN_CREDENTIAL_TTL_SECONDS } from '../../constants/webrtc.js';
|
||||
import type { GameSessionAuth, IceConfiguration, SignalingPacket } from '../shared/p2p-types.js';
|
||||
import { ApiError, type GameSessionStore } from './game-session-store.js';
|
||||
import type { RoomStore } from './rooms.js';
|
||||
|
||||
export interface RtcServerOptions { stunUrls?: string[]; turnUrls?: string[]; turnSecret?: string }
|
||||
export function registerSignaling(io: Server, sessions: GameSessionStore, rooms: RoomStore, options: RtcServerOptions = {}, now: () => number = Date.now) {
|
||||
const namespace = io.of('/signal');
|
||||
const timeout = (socket: Socket, ms: number) => {
|
||||
clearTimeout(socket.data.expiryTimer);
|
||||
socket.data.expiryTimer = setTimeout(() => socket.disconnect(true), Math.max(0, ms));
|
||||
socket.data.expiryTimer.unref?.();
|
||||
};
|
||||
async function notifyRoom(roomId: string) {
|
||||
const members = [...namespace.sockets.values()].filter(s => s.data.roomId === roomId && s.connected);
|
||||
for (const socket of members) {
|
||||
try {
|
||||
const room = await rooms.get(roomId, socket.data.auth.gameSessionId);
|
||||
socket.emit('room:state', room);
|
||||
socket.emit('signal:peers', { count: members.length, room });
|
||||
timeout(socket, room.status === 'paired' ? CONNECT_TIMEOUT_MS : room.expiresAt - now());
|
||||
} catch { socket.disconnect(true); }
|
||||
}
|
||||
}
|
||||
namespace.use((socket, next) => {
|
||||
void (async () => {
|
||||
const { gameSessionId, token, roomId } = socket.handshake.auth;
|
||||
const auth = { gameSessionId, token }; await sessions.get(auth);
|
||||
const room = await rooms.get(roomId, gameSessionId);
|
||||
if (room.status === 'closed') throw new ApiError(409, 'ROOM_CLOSED');
|
||||
socket.data.auth = auth; socket.data.roomId = room.id; socket.data.selfIndex = room.selfIndex;
|
||||
next();
|
||||
})().catch(() => next(new Error('방 연결 정보를 확인하세요.')));
|
||||
});
|
||||
namespace.on('connection', socket => {
|
||||
// A reconnect replaces only this participant's short-lived signaling connection.
|
||||
for (const existing of namespace.sockets.values()) if (existing !== socket && existing.data.auth.gameSessionId === socket.data.auth.gameSessionId) existing.disconnect(true);
|
||||
let count = 0, windowAt = now();
|
||||
socket.on('signal', (packet: SignalingPacket, ack?: (value: unknown) => void) => {
|
||||
if (typeof ack !== 'function') return;
|
||||
void (async () => {
|
||||
if (now() - windowAt >= 60000) { count = 0; windowAt = now(); }
|
||||
if (++count > 240) throw new ApiError(429, 'RATE_LIMIT');
|
||||
if (!packet || !['offer', 'answer', 'ice'].includes(packet.type) || !Number.isSafeInteger(packet.epoch) || packet.epoch < 0 || !packet.payload || typeof packet.payload !== 'object' || Buffer.byteLength(JSON.stringify(packet)) > MAX_PEER_MESSAGE_BYTES) throw new ApiError(400, 'INVALID_SIGNAL');
|
||||
const room = await rooms.get(socket.data.roomId, socket.data.auth.gameSessionId);
|
||||
if (room.status !== 'paired' || room.currentMatchId !== packet.matchId) throw new ApiError(409, 'STALE_MATCH');
|
||||
if ((packet.type === 'offer' && room.selfIndex !== 0) || (packet.type === 'answer' && room.selfIndex !== 1)) throw new ApiError(403, 'INVALID_ROLE');
|
||||
const other = [...namespace.sockets.values()].find(s => s.connected && s.data.roomId === room.id && s.data.auth.gameSessionId !== socket.data.auth.gameSessionId);
|
||||
if (!other) throw new ApiError(409, 'PEER_NOT_READY');
|
||||
other.emit('signal', { type: packet.type, matchId: packet.matchId, epoch: packet.epoch, payload: packet.payload });
|
||||
return { ok: true };
|
||||
})().then(ack, error => ack({ ok: false, code: error instanceof ApiError ? error.code : 'SERVER_ERROR' }));
|
||||
});
|
||||
socket.on('disconnect', () => { clearTimeout(socket.data.expiryTimer); void notifyRoom(socket.data.roomId); });
|
||||
void notifyRoom(socket.data.roomId);
|
||||
});
|
||||
return {
|
||||
namespace, notifyRoom,
|
||||
async rtcConfig(auth: GameSessionAuth, roomId: string): Promise<IceConfiguration> {
|
||||
await sessions.get(auth); await rooms.get(roomId, auth.gameSessionId);
|
||||
const iceServers: RTCIceServer[] = [];
|
||||
if (options.stunUrls?.length) iceServers.push({ urls: options.stunUrls });
|
||||
const expiresAt = now() + TURN_CREDENTIAL_TTL_SECONDS * 1000;
|
||||
if (options.turnUrls?.length && options.turnSecret) {
|
||||
const username = `${Math.floor(expiresAt / 1000)}:${auth.gameSessionId}`;
|
||||
iceServers.push({ urls: options.turnUrls, username, credential: createHmac('sha1', options.turnSecret).update(username).digest('base64') });
|
||||
}
|
||||
return { iceServers, expiresAt };
|
||||
},
|
||||
};
|
||||
}
|
||||
export type Signaling = ReturnType<typeof registerSignaling>;
|
||||
@@ -0,0 +1,39 @@
|
||||
import { createHash, randomBytes, randomUUID } from 'node:crypto';
|
||||
import { MongoClient, MongoServerError, type Db } from 'mongodb';
|
||||
import type { GuestSession } from '../shared/types.js';
|
||||
export interface PlayerDocument {
|
||||
_id: string; guestTokenHash: string; createdAt: Date; firstVisitAt?: Date;
|
||||
}
|
||||
export interface StatsStore {
|
||||
db: Db;
|
||||
createGuest(creationKey?: string): Promise<GuestSession>;
|
||||
findGuest(token: string): Promise<GuestSession | null>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
const hash = (token: string) => createHash('sha256').update(token).digest('hex');
|
||||
export async function createStatsStore(options: { mongoUri: string; databaseName: string }): Promise<StatsStore> {
|
||||
const client = new MongoClient(options.mongoUri, { serverSelectionTimeoutMS: 5000 });
|
||||
try {
|
||||
await client.connect();
|
||||
const db = client.db(options.databaseName);
|
||||
const players = db.collection<PlayerDocument>('players');
|
||||
await players.createIndex({ guestTokenHash: 1 }, { unique: true });
|
||||
return {
|
||||
db,
|
||||
async createGuest(creationKey) {
|
||||
if (creationKey !== undefined && !/^[A-Za-z0-9_-]{43}$/.test(creationKey)) throw new Error('INVALID_CREATION_KEY');
|
||||
const guestToken = creationKey ? createHash('sha256').update(`visitor-token:${creationKey}`).digest('base64url') : randomBytes(32).toString('base64url');
|
||||
const playerId = creationKey ? hash(`visitor:${creationKey}`) : randomUUID();
|
||||
try { await players.updateOne({ _id: playerId }, { $setOnInsert: { guestTokenHash: hash(guestToken), createdAt: new Date() } }, { upsert: true }); }
|
||||
catch (error) { if (!(error instanceof MongoServerError && error.code === 11000)) throw error; }
|
||||
return { playerId, guestToken };
|
||||
},
|
||||
async findGuest(token) {
|
||||
if (!/^[A-Za-z0-9_-]{43}$/.test(token)) return null;
|
||||
const player = await players.findOne({ guestTokenHash: hash(token) });
|
||||
return player ? { playerId: player._id, guestToken: token } : null;
|
||||
},
|
||||
async close() { await client.close(); },
|
||||
};
|
||||
} catch (error) { await client.close(); throw error; }
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { Mode, PlayerStats } from './types.js';
|
||||
export interface VisitMetadata { device: string; browser: string; source: string; invitation: boolean; medium?: string; campaign?: string; referrer?: string }
|
||||
export interface VisitSession { sessionId: string; playerId: string; startedAt: Date; lastAt: Date; lastSignalAt: Date; visible: boolean; activeMs: number; activeDays: string[]; metadata: VisitMetadata; metadataReady?: boolean; expiresAt: Date }
|
||||
export interface AnalyticsEvent {
|
||||
_id: string; playerId?: string; sessionId?: string; occurredAt: Date; eventType: string; environment: string;
|
||||
schemaVersion: number; appVersion: string; expiresAt?: Date; metadata?: VisitMetadata;
|
||||
screen?: string; action?: string; code?: string; reason?: string; delayed?: boolean;
|
||||
flowId?: string; matchId?: string; mode?: Mode;
|
||||
source?: 'client'; gameSessionId?: string; outcome?: 'win' | 'loss' | 'draw' | 'aborted';
|
||||
}
|
||||
export interface Presence { connected: number | null; active: number; waitingRooms: number | null; playingPeople: number | null; matches: number | null; source?: 'activity_reports'; observedAt?: Date; lastActivityAt?: Date | null }
|
||||
export interface Ratio { numerator: number; denominator: number; percent: number | null }
|
||||
export interface AnalyticsFilters { start: Date; end: Date; mode?: Mode; audience?: 'new' | 'returning'; device?: string; browser?: string; source?: string }
|
||||
export interface Cohort { day: string; size: number; d1: Ratio | null; d7: Ratio | null }
|
||||
export interface UserDetail { playerId: string; firstVisitAt?: Date; lastVisitAt?: Date; gameSessions: { gameSessionId: string; stats: PlayerStats; createdAt: Date; updatedAt: Date; expiresAt: Date }[]; sessions: VisitSession[]; events: AnalyticsEvent[]; page: number; pageSize: number; hasMore: boolean }
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { ActionId } from './types.js';
|
||||
import { ACTION_IDS } from '../../constants/gameplay.js';
|
||||
import { isLegalAction } from './rules.js';
|
||||
export function chooseBotAction(ownEnergy: number, opponentEnergy: number, random = Math.random): ActionId {
|
||||
const choices = ACTION_IDS.filter(a => isLegalAction(a, ownEnergy));
|
||||
const weighted = opponentEnergy === 0 ? ['charge' as const, 'charge' as const, ...choices] : choices;
|
||||
return weighted[Math.min(weighted.length - 1, Math.max(0, Math.floor(random() * weighted.length)))];
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { COSMETIC_COLORS, COSMETIC_EQUIPMENT, EQUIPMENT_IDS, EYEWEAR_IDS, HAIR_STYLES, SKIN_TONES, type EquipmentId, type HairStyle, type SkinToneId } from '../../constants/cosmetics.js';
|
||||
|
||||
export interface FighterAppearance { characterId: 0 | 1; colorId: string; hairStyle?: HairStyle; skinTone?: SkinToneId; equipment?: EquipmentId[] }
|
||||
|
||||
export const DEFAULT_APPEARANCE: FighterAppearance = { characterId: 0, colorId: 'white', hairStyle: 'black', skinTone: 'light', equipment: [] };
|
||||
|
||||
export function normalizeAppearance(value: unknown): FighterAppearance {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return { ...DEFAULT_APPEARANCE, equipment: [] };
|
||||
const appearance = value as Partial<FighterAppearance>;
|
||||
const characterId = appearance.characterId === 0 || appearance.characterId === 1 ? appearance.characterId : DEFAULT_APPEARANCE.characterId;
|
||||
const equipment = EQUIPMENT_IDS.filter(id => Array.isArray(appearance.equipment) && appearance.equipment.includes(id));
|
||||
const eyewear = EYEWEAR_IDS.find(id => equipment.includes(id));
|
||||
return {
|
||||
characterId,
|
||||
colorId: COSMETIC_COLORS.some(color => color.id === appearance.colorId) ? appearance.colorId! : DEFAULT_APPEARANCE.colorId,
|
||||
hairStyle: HAIR_STYLES.find(hair => hair.id === appearance.hairStyle)?.id ?? (characterId === 0 ? 'black' : 'white'),
|
||||
skinTone: SKIN_TONES.find(tone => tone.id === appearance.skinTone)?.id ?? (characterId === 0 ? 'light' : 'tan'),
|
||||
equipment: equipment.filter(id => !EYEWEAR_IDS.some(eye => eye === id) || id === eyewear),
|
||||
};
|
||||
}
|
||||
|
||||
export function appearanceKey(value?: FighterAppearance): string {
|
||||
if (!value) return '';
|
||||
const appearance = normalizeAppearance(value);
|
||||
return `${appearance.characterId}:${appearance.colorId}:${appearance.skinTone}:${appearance.hairStyle}:${appearance.equipment!.join(',')}`;
|
||||
}
|
||||
|
||||
export function availableEquipmentIds(stats: { wins: number; best: number }): EquipmentId[] {
|
||||
return COSMETIC_EQUIPMENT.filter(({ requirement }) => (requirement.type === 'wins' ? stats.wins : stats.best) >= requirement.value).map(item => item.id);
|
||||
}
|
||||
|
||||
export function availableColorIds(stats: { wins: number; best: number }): string[] {
|
||||
return COSMETIC_COLORS.filter(({ requirement }) => !requirement
|
||||
|| (requirement.type === 'wins' ? stats.wins : stats.best) >= requirement.value).map(color => color.id);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { MATCH_COUNTDOWN_MS, REVEAL_DURATION_MS, TURN_DURATION_MS } from '../../constants/gameplay.js';
|
||||
import { chooseBotAction } from './bot.js';
|
||||
import { isLegalAction, resolveTurn } from './rules.js';
|
||||
import { emptyPlayerStats, type LocalEndReason, type LocalMatchState, type MatchCheckpoint, type MatchConfig } from './p2p-types.js';
|
||||
import type { ActionId, Pair, PlayerStats, TurnResolution } from './types.js';
|
||||
|
||||
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,
|
||||
ownAction: null, rematch: [false, false], opponentPresent: true, opponentLeft: false,
|
||||
saveStatus: 'pending', aborted: false,
|
||||
};
|
||||
function finish(winnerIndex: 0 | 1 | null, reason: LocalEndReason, aborted = false) {
|
||||
if (state.phase === 'finished') return;
|
||||
state.phase = 'finished'; state.aborted = aborted; state.version++;
|
||||
state.finished = { matchId: config.matchId, winnerIndex, reason, stats: emptyPlayerStats(),
|
||||
revealEndsAt: state.reveal?.revealEndsAt ?? config.clock.now(), saved: false };
|
||||
}
|
||||
return {
|
||||
start(startsAt = config.clock.now()) {
|
||||
if (state.version !== 0) throw new Error('ALREADY_STARTED');
|
||||
state.startsAt = startsAt + MATCH_COUNTDOWN_MS; state.version++;
|
||||
},
|
||||
beginTurn(startsAt = config.clock.now()): boolean {
|
||||
if (state.phase === 'selecting' || state.phase === 'finished') return false;
|
||||
const earliest = state.phase === 'countdown' ? state.startsAt : state.reveal!.revealEndsAt;
|
||||
if (startsAt < earliest || config.clock.now() < earliest) return false;
|
||||
state.phase = 'selecting'; state.turnId++; state.version++;
|
||||
state.startsAt = startsAt; state.deadline = startsAt + TURN_DURATION_MS;
|
||||
state.ownAction = null; state.reveal = undefined;
|
||||
bot = config.mode === 'bot' ? chooseBotAction(state.energy[1], state.energy[0], config.random) : null;
|
||||
return true;
|
||||
},
|
||||
lockAction(action: ActionId): boolean {
|
||||
if (state.phase !== 'selecting' || config.clock.now() < state.startsAt || config.clock.now() >= state.deadline || state.ownAction || !isLegalAction(action, state.energy[config.selfIndex])) return false;
|
||||
state.ownAction = action; state.version++; return true;
|
||||
},
|
||||
botAction: () => bot,
|
||||
resolve(actions: Pair<ActionId | null>): TurnResolution {
|
||||
if (state.phase !== 'selecting') throw new Error('NOT_SELECTING');
|
||||
if (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 };
|
||||
}
|
||||
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>,
|
||||
energyBefore: [...state.energy], ...result, revealedAt: now, revealEndsAt: now + REVEAL_DURATION_MS };
|
||||
state.energy = [...result.energy]; state.version++;
|
||||
if (result.winnerIndex !== null) finish(result.winnerIndex, 'hit');
|
||||
else state.phase = 'revealing';
|
||||
return structuredClone(result);
|
||||
},
|
||||
finish,
|
||||
present(revealedAt: number) {
|
||||
if (!Number.isFinite(revealedAt)) throw new Error('INVALID_TIME');
|
||||
if (state.reveal) { state.reveal.revealedAt = revealedAt; state.reveal.revealEndsAt = revealedAt + REVEAL_DURATION_MS; }
|
||||
if (state.finished) state.finished.revealEndsAt = state.reveal?.revealEndsAt ?? revealedAt;
|
||||
state.version++;
|
||||
},
|
||||
presence(present: boolean) { state.opponentPresent = present; state.version++; },
|
||||
requestRematch(slot: 0 | 1) { state.rematch[slot] = true; state.version++; },
|
||||
resumeTurn(checkpoint: MatchCheckpoint, startsAt: number) {
|
||||
if (checkpoint.matchId !== config.matchId || checkpoint.state.selfIndex !== config.selfIndex) throw new Error('MATCH_MISMATCH');
|
||||
if (checkpoint.state.phase === 'finished') throw new Error('ALREADY_FINISHED');
|
||||
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;
|
||||
state.startsAt = startsAt; state.deadline = startsAt + TURN_DURATION_MS; state.opponentPresent = true; state.version++;
|
||||
},
|
||||
abort(reason: 'connection_failed' | 'protocol_error') { finish(null, reason, true); },
|
||||
saved(stats: PlayerStats) {
|
||||
state.saveStatus = 'saved';
|
||||
if (state.finished) { state.finished.stats = structuredClone(stats); state.finished.saved = true; }
|
||||
state.version++;
|
||||
},
|
||||
saveFailed() { state.saveStatus = 'failed'; state.version++; },
|
||||
view(now = config.clock.now()): LocalMatchState { return structuredClone({ ...state, clockNow: now }); },
|
||||
checkpoint(): MatchCheckpoint { return structuredClone({ matchId: config.matchId, state: { ...state, clockNow: config.clock.now() }, botAction: bot }); },
|
||||
restore(checkpoint: MatchCheckpoint) {
|
||||
if (checkpoint.matchId !== config.matchId || checkpoint.state.matchId !== config.matchId || checkpoint.state.mode !== config.mode || checkpoint.state.selfIndex !== config.selfIndex) throw new Error('MATCH_MISMATCH');
|
||||
state = { ...structuredClone(checkpoint.state), version: Math.max(state.version, checkpoint.state.version) + 1 }; bot = checkpoint.botAction;
|
||||
},
|
||||
};
|
||||
}
|
||||
export type MatchEngine = ReturnType<typeof createMatchEngine>;
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { ActionId, EndReason, MatchFinished, MatchState, Mode, Pair, PlayerStats } from './types.js';
|
||||
import type { FighterAppearance } from './cosmetics.js';
|
||||
|
||||
export type LocalOutcome = 'win' | 'loss' | 'draw' | 'aborted';
|
||||
export type LocalEndReason = EndReason | 'connection_failed' | 'protocol_error';
|
||||
export interface ActiveMatch { matchId: string; seq: number; mode: Mode; roomId?: string; appearances?: Pair<FighterAppearance> }
|
||||
export interface ResultSubmission extends ActiveMatch { outcome: LocalOutcome; reason: LocalEndReason }
|
||||
export interface RecordAck { status: 'applied' | 'duplicate' | 'stale'; stats: PlayerStats }
|
||||
export interface GameSessionAuth { gameSessionId: string; token: string }
|
||||
export interface GameSessionState extends GameSessionAuth {
|
||||
stats: PlayerStats; lastAppliedSeq: number; activeMatch?: ActiveMatch;
|
||||
playerId?: string; guestToken?: string;
|
||||
}
|
||||
export interface PeerRoom {
|
||||
id: string; code: string; currentMatchId: string; round: number; selfIndex: 0 | 1;
|
||||
count: number; status: 'waiting' | 'paired' | 'closed'; expiresAt: number; rematch: Pair<boolean>;
|
||||
appearances?: Pair<FighterAppearance>;
|
||||
}
|
||||
export interface SignalingPacket {
|
||||
type: 'offer' | 'answer' | 'ice'; matchId: string; epoch: number;
|
||||
payload: RTCSessionDescriptionInit | RTCIceCandidateInit;
|
||||
}
|
||||
export interface IceConfiguration { iceServers: RTCIceServer[]; expiresAt: number }
|
||||
export interface LocalMatchState extends Omit<MatchState, 'serverNow' | 'finished'> {
|
||||
clockNow: number; aborted: boolean;
|
||||
appearances?: Pair<FighterAppearance>;
|
||||
ranked?: boolean;
|
||||
finished?: Omit<MatchFinished, 'reason'> & { reason: LocalEndReason };
|
||||
}
|
||||
export interface MatchCheckpoint { matchId: string; state: LocalMatchState; botAction: ActionId | null }
|
||||
export interface MatchConfig {
|
||||
matchId: string; mode: Mode; selfIndex: 0 | 1; clock: { now(): number }; random?: () => number;
|
||||
}
|
||||
export interface PeerHeader {
|
||||
protocolVersion: number; rulesVersion: string; matchId: string; connectionEpoch: number; turnId: number;
|
||||
}
|
||||
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:start'; startsAt: number; resume?: boolean }
|
||||
| { type: 'turn:commit'; digest: string; slot: 0 | 1 }
|
||||
| { type: 'turn:reveal'; action: ActionId | null; nonce: string; slot: 0 | 1 }
|
||||
| { type: 'turn:resolved' | 'turn:ack'; digest: string }
|
||||
| { type: 'turn:present'; revealedAt: number }
|
||||
| { type: 'match:leave' | 'match:leave-ack' | 'match:rematch' }
|
||||
| { type: 'resume' | 'resume:ack'; checkpoint: PublicCheckpoint }
|
||||
);
|
||||
export interface PublicCheckpoint {
|
||||
turnId: number; energy: 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 });
|
||||
return { bot: mode(), friend: mode() };
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { RULES_VERSION } from '../../constants/gameplay.js';
|
||||
import { PROTOCOL_VERSION } from '../../constants/webrtc.js';
|
||||
import { isLegalAction } from './rules.js';
|
||||
import type { PeerMessage } from './p2p-types.js';
|
||||
import type { ActionId, Pair } from './types.js';
|
||||
|
||||
export async function digestValue(value: unknown): Promise<string> {
|
||||
const bytes = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(JSON.stringify(value)));
|
||||
return Array.from(new Uint8Array(bytes), b => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
interface ExchangeConfig {
|
||||
matchId: string; epoch: number; turnId: number; slot: 0 | 1; energy: Pair<number>;
|
||||
send(message: PeerMessage): void; onResolved(actions: Pair<ActionId | null>): void;
|
||||
onPhase?: (phase: 'commit' | 'reveal' | 'complete') => void;
|
||||
onPrivateState?: (state: { action: ActionId | null; nonce: string }) => void;
|
||||
}
|
||||
export function createTurnExchange(config: ExchangeConfig) {
|
||||
const other = config.slot === 0 ? 1 : 0;
|
||||
let own: { action: ActionId | null; nonce: string } | undefined, ownDigest: string | undefined, remoteDigest: string | undefined;
|
||||
let revealed = false, received = false, completed = false, remoteAction: ActionId | null = null;
|
||||
let queue = Promise.resolve();
|
||||
const header = { protocolVersion: PROTOCOL_VERSION, rulesVersion: RULES_VERSION, matchId: config.matchId, connectionEpoch: config.epoch, turnId: config.turnId };
|
||||
const commitment = (slot: 0 | 1, action: ActionId | null, nonce: string) => digestValue([config.matchId, config.epoch, config.turnId, slot, action, nonce]);
|
||||
function tryComplete() {
|
||||
if (!revealed || !received || completed) return;
|
||||
completed = true; config.onPhase?.('complete');
|
||||
const actions: Pair<ActionId | null> = [null, null]; actions[config.slot] = own!.action; actions[other] = remoteAction;
|
||||
config.onResolved(actions);
|
||||
}
|
||||
function reveal() {
|
||||
if (!ownDigest || !remoteDigest || revealed) return;
|
||||
revealed = true; config.onPhase?.('reveal');
|
||||
config.send({ ...header, type: 'turn:reveal', slot: config.slot, ...own! }); tryComplete();
|
||||
}
|
||||
async function receive(message: PeerMessage) {
|
||||
if (message.matchId !== config.matchId || message.connectionEpoch !== config.epoch || message.turnId !== config.turnId || message.protocolVersion !== PROTOCOL_VERSION || message.rulesVersion !== RULES_VERSION) return;
|
||||
if (message.type !== 'turn:commit' && message.type !== 'turn:reveal') return;
|
||||
if (message.slot !== other) throw new Error('INVALID_SLOT');
|
||||
if (message.type === 'turn:commit') {
|
||||
if (typeof message.digest !== 'string' || !/^[a-f0-9]{64}$/.test(message.digest)) throw new Error('INVALID_COMMITMENT');
|
||||
if (remoteDigest && remoteDigest !== message.digest) throw new Error('COMMITMENT_CHANGED');
|
||||
remoteDigest = message.digest; reveal(); return;
|
||||
}
|
||||
if (!ownDigest || !remoteDigest) throw new Error('COMMIT_REQUIRED');
|
||||
if (typeof message.nonce !== 'string' || !/^[a-f0-9]{64}$/.test(message.nonce)) throw new Error('INVALID_NONCE');
|
||||
if (await commitment(other, message.action, message.nonce) !== remoteDigest) throw new Error('COMMITMENT_MISMATCH');
|
||||
if (message.action !== null && !isLegalAction(message.action, config.energy[other])) throw new Error('INVALID_ACTION');
|
||||
remoteAction = message.action; received = true; tryComplete();
|
||||
}
|
||||
return {
|
||||
async commit(action: ActionId | null) {
|
||||
if (own) { if (own.action !== action) throw new Error('ACTION_LOCKED'); return; }
|
||||
if (action !== null && !isLegalAction(action, config.energy[config.slot])) throw new Error('INVALID_ACTION');
|
||||
const nonce = Array.from(crypto.getRandomValues(new Uint8Array(32)), b => b.toString(16).padStart(2, '0')).join('');
|
||||
own = { action, nonce }; config.onPrivateState?.(own); config.onPhase?.('commit');
|
||||
ownDigest = await commitment(config.slot, action, nonce);
|
||||
config.send({ ...header, type: 'turn:commit', slot: config.slot, digest: ownDigest }); reveal();
|
||||
},
|
||||
receive(message: PeerMessage) { const operation = queue.then(() => receive(message)); queue = operation.catch(() => {}); return operation; },
|
||||
};
|
||||
}
|
||||
export type TurnExchange = ReturnType<typeof createTurnExchange>;
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { FighterAppearance } from './cosmetics.js';
|
||||
import type { LocalMatchState } from './p2p-types.js';
|
||||
import type { ModeStats } from './types.js';
|
||||
import type { EquipmentId } from '../../constants/cosmetics.js';
|
||||
|
||||
export interface PlayerProfile {
|
||||
playerId: string;
|
||||
displayName: string;
|
||||
appearance: FighterAppearance;
|
||||
stats: ModeStats;
|
||||
unlockedColors: string[];
|
||||
unlockedEquipment: EquipmentId[];
|
||||
}
|
||||
export interface RankedStatus {
|
||||
status: 'idle' | 'queued' | 'matched';
|
||||
profile: PlayerProfile;
|
||||
match?: LocalMatchState;
|
||||
}
|
||||
export interface LeaderboardEntry {
|
||||
rank: number;
|
||||
playerId: string;
|
||||
displayName: string;
|
||||
wins: number;
|
||||
best: number;
|
||||
}
|
||||
export interface Leaderboard { entries: LeaderboardEntry[]; self?: LeaderboardEntry }
|
||||
@@ -0,0 +1,16 @@
|
||||
import { ACTIONS } from '../../constants/gameplay.js';
|
||||
import type { ActionId, Pair, TurnResolution } from './types.js';
|
||||
export function isLegalAction(action: unknown, energy: number): action is ActionId {
|
||||
return typeof action === 'string' && Object.hasOwn(ACTIONS, action)
|
||||
&& Number.isSafeInteger(energy) && energy >= ACTIONS[action as ActionId].cost;
|
||||
}
|
||||
export function resolveTurn(energy: Pair<number>, actions: Pair<string>): TurnResolution {
|
||||
if (!isLegalAction(actions[0], energy[0]) || !isLegalAction(actions[1], energy[1])) throw new Error('INVALID_ACTION');
|
||||
const [a, b] = actions as Pair<ActionId>;
|
||||
const beats = (attack: ActionId, other: ActionId) =>
|
||||
attack === 'blast' ? other === 'charge' : attack === 'beam' && ['charge', 'blast', 'guard'].includes(other);
|
||||
const winnerIndex = beats(a, b) ? 0 : beats(b, a) ? 1 : null;
|
||||
const hitIndex = winnerIndex === null ? null : winnerIndex === 0 ? 1 : 0;
|
||||
const after = energy.map((n, i) => n - ACTIONS[actions[i] as ActionId].cost + (actions[i] === 'charge' && hitIndex !== i ? 1 : 0)) as Pair<number>;
|
||||
return { energy: after, winnerIndex, hitIndex };
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { ACTIONS } from '../../constants/gameplay.js';
|
||||
export type ActionId = keyof typeof ACTIONS;
|
||||
export type Pair<T> = [T, T];
|
||||
export type Mode = 'bot' | 'friend';
|
||||
export type Result = 'win' | 'loss' | 'draw';
|
||||
export type Phase = 'countdown' | 'selecting' | 'revealing' | 'finished';
|
||||
export type EndReason = 'hit' | 'timeout' | 'forfeit' | 'disconnect' | 'double_timeout' | 'double_disconnect';
|
||||
export interface TurnResolution { energy: Pair<number>; winnerIndex: 0 | 1 | null; hitIndex: 0 | 1 | null }
|
||||
export interface TurnReveal extends TurnResolution {
|
||||
matchId: string; turnId: number; actions: Pair<ActionId>; energyBefore: Pair<number>;
|
||||
revealedAt: number; revealEndsAt: number;
|
||||
}
|
||||
export interface ModeStats { current: number; best: number; wins: number; losses: number; draws: number }
|
||||
export type PlayerStats = Record<Mode, ModeStats>;
|
||||
export interface GuestSession { playerId: string; guestToken: string }
|
||||
export interface MatchFinished {
|
||||
matchId: string; winnerIndex: 0 | 1 | null; reason: EndReason; stats: PlayerStats;
|
||||
revealEndsAt: number; saved: boolean;
|
||||
}
|
||||
export interface MatchState {
|
||||
matchId: string; mode: Mode; phase: Phase; version: number; selfIndex: 0 | 1;
|
||||
energy: Pair<number>; turnId: number; serverNow: number; startsAt: number; deadline: number;
|
||||
ownAction: ActionId | null; reveal?: TurnReveal; finished?: MatchFinished;
|
||||
rematch: Pair<boolean>; opponentPresent: boolean; opponentLeft: boolean; saveStatus: 'pending' | 'saved' | 'failed';
|
||||
}
|
||||
export interface RoomState { code: string; count: number; status: 'waiting' | 'playing' | 'finished' | 'closed'; expiresAt: number }
|
||||
export interface SessionReady { guestToken: string; stats: PlayerStats; room: RoomState | null; match: MatchState | null; serverNow: number }
|
||||
export type Ack<T = unknown> = { ok: true; data: T } | { ok: false; code: string; message: string };
|
||||
Reference in New Issue
Block a user