commit 4f24129e298f986c095a5f3fc9b62befbe9f7fe1 Author: Horoli Date: Tue Oct 13 11:15:26 2026 +0900 Initial commit: ssessesse ki battle diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e9c31ad --- /dev/null +++ b/.env.example @@ -0,0 +1,25 @@ +# Fastify + Socket.IO use one application port. +HOST=0.0.0.0 +PORT=3000 + +# Existing standalone MongoDB endpoint, verified with a read-only hello command. +# If authentication is required, set credentials/authSource in your private environment. +MONGODB_URI=mongodb://172.16.0.7:27017/?directConnection=true + +# Proposed application database name; the screenshot's "db" is a connection label. +MONGODB_DATABASE=dragon_ball + +# Set a dedicated test endpoint before database integration tests. +# Tests must use a separate, isolated database and never reuse the application database. +TEST_MONGODB_URI= + +# Set BOTH to enable /admin/analytics. Keep real credentials in your private .env. +# Use HTTPS for the deployed administrator page and API. +ADMIN_USER= +ADMIN_PASSWORD= + +# WebRTC: comma-separated ICE URLs. Use HTTPS outside localhost. +# TURN uses coturn-compatible expiring HMAC credentials; never expose this secret to Vite. +STUN_URLS= +TURN_URLS= +TURN_SHARED_SECRET= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ca3481e --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +node_modules/ +dist/ +.env +.env.* +!.env.example +.work/ +.tmp/ +.superpowers/ +*.log diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..a17bd50 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,30 @@ +# 프로젝트 작업 규칙 + +## UI 문구 최소화 + +화면 문구는 사용자의 행동과 판단에 필요한 정보만 표시한다. + +### 허용 +- 버튼·메뉴·입력 항목의 기능을 식별하는 이름 +- 스킬·아이템의 실제 효과, 비용, 지속시간, 사용 조건 +- 현재 상태, 작업 결과, 오류와 해결 방법 +- 조작에 반드시 필요한 안내 + +### 금지 +- 기능을 소개하거나 장점을 홍보하는 문구 +- 제목·버튼·화면에서 이미 알 수 있는 내용을 반복하는 설명 +- 분위기를 채우기 위한 부제, 슬로건, 환영 문구 +- 구현 방식, 기술 스택, 프로토타입 제작 과정에 관한 설명 +- 요청하지 않은 도움말, 팁, 예시 문구 + +### 작성 기준 +- 문구를 삭제해도 사용자가 기능과 결과를 이해할 수 있으면 삭제한다. +- 버튼은 행동 이름으로 작성한다. 예: "저장", "장착", "전투 시작". +- 효과는 실제 동작을 짧고 정확하게 표현한다. 예: "강력한 공격으로 적을 압도합니다" 대신 "적 1명에게 피해 120". +- 예시 수치를 그대로 사용하지 말고, 실제 구현에 맞는 수치와 조건만 표시한다. 수치와 조건을 임의로 만들지 않는다. +- 상세 설명이 꼭 필요하면 해당 조작 근처나 도움말에 배치한다. +- 접근성에 필요한 이름과 레이블은 유지한다. + +### 완료 전 검토 +- 새로 추가하거나 수정한 모든 화면 문구를 검토한다. +- 행동·효과·조건·상태·오류 이해에 기여하지 않는 문구를 제거한다. diff --git a/README.md b/README.md new file mode 100644 index 0000000..96f77c5 --- /dev/null +++ b/README.md @@ -0,0 +1,104 @@ +# 드래곤볼 + +가입 없이 봇 또는 친구와 대전하는 5개 행동 손놀이 게임. + +## 실행 + +Node.js 24 이상, MongoDB Standalone이 필요하다. + +```powershell +npm ci +Copy-Item .env.example .env +npm run build +npm start +``` + +`.env`의 `MONGODB_URI`, `MONGODB_DATABASE`, `HOST`, `PORT`를 설정한다. 기본 접속은 `http://localhost:3000`이다. 외부 브라우저에서는 **HTTPS**가 필요하다. 관리자 인증과 Web Crypto를 포함한 기능을 보호하는 프록시 뒤에 배치한다. + +Fastify HTTP API·페이지·Socket.IO `/signal`은 한 앱 포트와 MongoDB 연결 풀을 공유한다. 앱 다중 인스턴스의 시그널링 공유는 구현하지 않았다. 개발 중 클라이언트는 다시 빌드하고, 서버는 `npm run dev`로 감시 실행한다. 실행 중인 배포 폴더를 덮어쓰지 않고 새 빌드를 준비한 뒤 교체한다. + +## 대전과 연결 + +기존 화면·캐릭터·연출·공통 판정을 재사용한다. 봇 대전은 브라우저에서 실행한다. 친구 대전은 reliable ordered WebRTC DataChannel로 턴을 교환한다. + +| 행동 | 기 소모 | 효과 | +|---|---:|---| +| 으 | 0 | 생존 시 기 +1 | +| 파 | 1 | 으를 맞힘 | +| 막기 | 0 | 파를 막음 | +| 에네르기파 | 3 | 으·파·막기를 맞힘 | +| 순간이동 | 1 | 파·에네르기파 회피 | + +준비 3초 → 선택 4초 → 공개 1.2초. 기 0으로 시작하며 명중 한 번으로 종료한다. 첫 유효 선택을 잠그고 마감 전에 상대 선택을 공개하지 않는다. 같은 공격은 상쇄되며 공격·회피 비용은 실패해도 소모한다. 봇은 턴 시작 시점의 양쪽 기만 보고 미리 선택한다. + +화면에는 내 기만 표시하고 행동 버튼은 내 캐릭터 아래에 배치한다. 상대 기는 화면에서만 숨긴다. P2P 판정·복원을 위해 양쪽 기가 브라우저에 보관되므로 개발자도구에서의 조회나 공개 행동을 통한 기 추적을 차단하지 않는다. + +친구 대전은 마감 후 선택 해시를 교환하고 양쪽 해시가 모이면 선택과 nonce를 공개한다. 양쪽 브라우저가 독립 계산한 결과 해시와 확인 메시지가 일치해야 공개·기록한다. 한쪽 미입력은 패배, 양쪽 미입력은 무승부다. 명시적 나가기를 상대가 확인하면 기권 처리한다. + +Socket.IO는 방 대기와 SDP/ICE 협상에만 사용하고 DataChannel 준비 확인 후 닫는다. 정상 대전 중 서버는 게임 객체·행동·판정 타이머를 유지하지 않는다. 서버 재시작 중에도 이미 연결된 대전은 진행하며 HTTP 결과 저장은 복구 후 재시도한다. + +연결이 끊기면 최대 10초간 새 연결 epoch로 복구한다. 확정되지 않은 선택을 버리고 마지막 상호 확인 상태에서 선택 시간을 다시 시작한다. 복구 불가·합의 불일치는 `aborted`로 종료하며 승·패·무·연승을 바꾸지 않는다. 처음 연결은 최대 15초다. 모바일 백그라운드 정지나 장시간 탭 중단까지 연속 플레이를 보장하지 않는다. + +## STUN / TURN + +`.env.example`의 `STUN_URLS`, `TURN_URLS`, `TURN_SHARED_SECRET`을 운영 환경에 맞게 설정한다. URL은 쉼표로 구분한다. 직접 연결이 불가능한 네트워크에서는 TURN이 필요하다. TURN 포트·인증·방화벽과 중계 트래픽 비용은 별도 운영 대상이다. + +서버는 방 참가자에게 coturn 호환 단기 HMAC 자격 증명만 제공한다. 공유 비밀값은 서버 환경에만 두며 `VITE_*` 변수나 번들에 넣지 않는다. ICE 설정이 비어 있어도 일부 로컬 네트워크에서 동작할 수 있지만 외부 접속 가능성을 뜻하지 않는다. 실제 TURN 경로와 모바일 네트워크 전환 검증 범위는 [전환 검증 기록](docs/verification/webrtc-migration.md)을 따른다. + +## 접속 세션별 기록과 임시 방 + +- `gameSessionId`: 탭 `sessionStorage`의 게임 기록 단위. 같은 탭 새로고침은 복원하고, 저장 정보 없는 새 탭은 새 세션이다. 브라우저의 탭 복제·복원으로 정보가 복제될 수 있으며 한 세션은 미정산 대전 1개만 허용한다. +- `playerId`: 기존 익명 방문자 단위. 방문자 토큰은 `localStorage`에 보관한다. +- 분석 `sessionId`: 30분 비활동 후 나누는 방문 세션. 게임 기록 세션과 별개다. + +게임 세션은 생성·대전 등록·결과 저장 시점부터 90일 TTL을 적용한다. 저장소 사용이 막히면 메모리 세션을 사용하며 새로고침 복원을 제공하지 못한다. + +봇·친구 승/패/무·현재/최고 연승은 `game_sessions` 단일 문서에 보관한다. 승리는 연승 +1, 패배는 0, 무승부는 유지한다. 등록한 `matchId`와 순서 `seq`가 일치하는 결과만 원자적으로 반영하고 처리 영수증도 함께 저장한다. 중복 재전송·뒤늦은 과거 결과는 카운트를 올리지 않는다. 기록 요청에는 upsert를 사용하지 않고 세션 생성에만 사용한다. 다중 문서 트랜잭션은 필요 없다. + +제출 결과는 클라이언트 보고로 받아들인다. 공개 경쟁 랭킹 수준의 서버 승리 검증이나 부정행위 방지를 제공하지 않는다. + +결과 전송 실패는 2초·5초·10초·이후 30초 간격 및 온라인 복귀 시 재시도한다. 같은 탭에 미전송 결과를 보관하며 저장 확인 전에는 새 대전을 시작하지 않는다. + +방은 `rooms` 임시 문서다. 6자리 코드나 초대 링크로 참가하며 정원·참가자·만료를 API에서도 검사한다. 대기 10분, 참가 완료 24시간 TTL이며 재대전 때 갱신한다. 양쪽 동의 후 새 `matchId`를 부여한다. 방 만료가 이미 등록한 결과 저장을 막지 않는다. + +`players`는 익명 방문자 식별용으로 유지한다. 전환 전 `matches` 컬렉션과 서버 판정 전적·상세 조회는 제거했다. + +사용하는 컬렉션은 `players`, `game_sessions`, `rooms`, `analytics_events`, `analytics_sessions`, `analytics_presence_samples`, `analytics_metadata`다. + +## 운영 분석 + +`ADMIN_USER`, `ADMIN_PASSWORD`를 모두 설정하면 `/admin/analytics`, `/api/admin/analytics/*`를 HTTP Basic 인증으로 연다. 미설정이면 404다. 자격 증명은 서버에만 저장하며 외부 운영은 HTTPS를 사용한다. + +새 분석은 HTTP 배치 최대 20건/5초, 보이는 화면의 활동 보고는 최대 60초당 한 번이다. 숨김 전환의 추가 보고도 분당 한 번으로 제한한다. 턴별 행동 저장·DB touch는 제거했다. 최근 90초 활동으로 활성 방문자를 추정하며 대전 중 사람 수·정확한 현재 대전 수는 미수집으로 표시한다. + +관리자 화면은 클라이언트 대전 요약·접속 세션별 전적과 방문·유입·화면·활동 시간·방문 재방문 분석을 표시한다. 대전 수는 클라이언트 시작 보고의 ID로 중복 제거하고, 친구 한 판의 결과 보고는 참가자별 최대 2건이다. 전환 전 서버 판정에 의존하던 행동·턴 상세·완료율·플레이 전환율·재대전율은 제거했다. + +기존 DB 정리는 앱을 중지한 상태에서 아래 명령으로 실행한다. 기본 실행은 대상 건수만 조회하며, 적용할 때는 새 백업 파일이 필요하다. 사용자 식별·방문 이력·현재 세션 전적·임시 방은 보존하고, `matches`, 구형 서버 전용 이벤트·접속 표본, `players.firstMatchAt`만 제거한다. 서버 시작 시 자동 삭제하지 않는다. + +```powershell +node --env-file-if-exists=.env --import tsx scripts/cleanup-legacy-data.ts --database dragon_ball +node --env-file-if-exists=.env --import tsx scripts/cleanup-legacy-data.ts --database dragon_ball --apply --backup .work/legacy-cleanup/removed-data.ejson +``` + +이벤트·방문 세션·활동 표본은 90일 보관한다. 원문 UA·URL·방 코드·인증 토큰·IP·정밀 위치·오류 본문은 수집하지 않는다. 유입 도메인과 허용 캠페인 분류만 보관한다. 분석 전송 대기는 클라이언트 최대 100건·30분·6회 시도, 서버 최대 100건·30분이다. 분석 장애는 게임 진행과 결과 집계를 막지 않는다. + +## 검증 + +```powershell +$env:TEST_MONGODB_URI='mongodb://172.16.0.7:27017/?directConnection=true' +npm test +npm run typecheck +npm run build +``` + +테스트는 `dragon_ball_test_*` DB만 생성·정리한다. 환경변수가 없으면 DB 테스트가 skip되므로 통합 검증으로 간주하지 않는다. + +`tests/preview-server.ts`는 포트 3001의 로컬 검증 전용 서버다. `UI_PREVIEW_CLIENT`로 별도 빌드 경로를 지정할 수 있다. `/__test/peer`는 소켓·RTC 상태를 노출하는 진단 화면, `/__test/mobile`은 390×844 프레임(`?width=320&height=568` 지원), `/__test/desktop`은 1280×720 프레임이다. `/__test/admin`의 테스트 인증 프록시는 운영 빌드에 포함되지 않는다. + +`node --import tsx tests/webrtc-load.ts`는 3천 세션의 60초 초기 유입과 15분 HTTP/DB 부하를 측정한다. 임의 이름의 전용 테스트 DB와 별도 프로세스를 사용한다. 실제 WebRTC 1,500쌍 또는 TURN 부하 검증을 대신하지 않는다. + +[전환 지시서](docs/superpowers/plans/2026-10-12-webrtc-migration.md) · [전환 검증](docs/verification/webrtc-migration.md) · [진행 기록](docs/implementation-progress.md) + +## 후속 범위 + +퀵매치·공개 랭킹·MMR·관전·채팅은 포함하지 않는다. 다중 앱 인스턴스, 분산 브라우저 1,500쌍, 운영 TURN 비용·실제 모바일 네트워크 검증은 별도 환경에서 확인한다. diff --git a/constants/analytics.ts b/constants/analytics.ts new file mode 100644 index 0000000..d239c7d --- /dev/null +++ b/constants/analytics.ts @@ -0,0 +1,16 @@ +export const VISIT_SESSION_MS = 30 * 60 * 1000; +export const ACTIVITY_INTERVAL_MS = 60000; +export const ANALYTICS_BATCH_SIZE = 20; +export const ACTIVE_WINDOW_MS = 90000; +export const PRESENCE_INTERVAL_MS = 10000; +export const RETENTION_MS = 90 * 24 * 60 * 60 * 1000; +export const RETRY_QUEUE_LIMIT = 100; +export const RETRY_MAX_AGE_MS = VISIT_SESSION_MS; +export const RETRY_INTERVAL_MS = 5000; +export const MAX_EVENTS_PER_MINUTE = 120; +export const RETENTION_SWEEP_MS = 60 * 60 * 1000; +export const CLIENT_EVENTS = ['page_view', 'screen_view', 'ui_action', 'client_error', 'game_started', 'game_finished'] as const; +export const SCREENS = ['home', 'join', 'waiting', 'match', 'result'] as const; +export const UI_ACTIONS = ['bot_start', 'room_create', 'room_join', 'rematch'] as const; +export const CLIENT_ERROR_CODES = ['storage_unavailable', 'connection_failed', 'renderer_failed'] as const; +export const CAMPAIGN_VALUES = ['friend', 'organic', 'social', 'email', 'launch'] as const; diff --git a/constants/branding.ts b/constants/branding.ts new file mode 100644 index 0000000..21dfc54 --- /dev/null +++ b/constants/branding.ts @@ -0,0 +1 @@ +export const GAME_NAME = '쎄쎄쎄:기공대전'; diff --git a/constants/cosmetics.ts b/constants/cosmetics.ts new file mode 100644 index 0000000..7044b1d --- /dev/null +++ b/constants/cosmetics.ts @@ -0,0 +1,40 @@ +export interface CosmeticColor { + id: string; + name: string; + suit: string; + suitDark: string; + trim: string; + requirement?: { type: 'wins' | 'streak'; value: number }; +} + +export const EYEWEAR_IDS = ['glasses', 'sunglasses'] as const; +export const EQUIPMENT_IDS = ['shoes', 'wristbands', 'headband', ...EYEWEAR_IDS] as const; +export type EquipmentId = typeof EQUIPMENT_IDS[number]; +export const COSMETIC_EQUIPMENT: readonly { id: EquipmentId; name: string; requirement: { type: 'wins' | 'streak'; value: number } }[] = [ + { id: 'shoes', name: '신발', requirement: { type: 'wins', value: 5 } }, + { id: 'wristbands', name: '아대', requirement: { type: 'streak', value: 3 } }, + { id: 'headband', name: '머리띠', requirement: { type: 'wins', value: 20 } }, + { id: 'glasses', name: '안경', requirement: { type: 'streak', value: 50 } }, + { id: 'sunglasses', name: '선글라스', requirement: { type: 'streak', value: 100 } }, +]; +export const HAIR_STYLES = [ + { id: 'black', name: '검정 뾰족머리' }, { id: 'white', name: '백발 뾰족머리' }, { id: 'bald', name: '빡빡이' }, +] as const; +export type HairStyle = typeof HAIR_STYLES[number]['id'] | 'default'; +export const SKIN_TONES = [ + { id: 'light', name: '밝은 피부', skin: '#efc8a5', shade: '#bd896b', light: '#ffe4c8' }, + { id: 'tan', name: '구릿빛 피부', skin: '#a96f49', shade: '#6e4938', light: '#d29a70' }, +] as const; +export type SkinToneId = typeof SKIN_TONES[number]['id']; + +// Confirmed unlock thresholds; shared by availability checks and previews. +export const COSMETIC_COLORS: readonly CosmeticColor[] = [ + { id: 'white', name: '흰색', suit: '#f0eee5', suitDark: '#abaea8', trim: '#535f68' }, + { id: 'orange', name: '주황', suit: '#aa5740', suitDark: '#613b33', trim: '#d3bf99', requirement: { type: 'wins', value: 5 } }, + { id: 'blue', name: '파랑', suit: '#455862', suitDark: '#293840', trim: '#d7d5bf', requirement: { type: 'wins', value: 20 } }, + { id: 'red', name: '빨강', suit: '#ad4148', suitDark: '#632c36', trim: '#ebc49c', requirement: { type: 'wins', value: 50 } }, + { id: 'green', name: '초록', suit: '#52765a', suitDark: '#304c3b', trim: '#d4ce9e', requirement: { type: 'streak', value: 3 } }, + { id: 'purple', name: '보라', suit: '#79608e', suitDark: '#493955', trim: '#ddc6aa', requirement: { type: 'streak', value: 5 } }, + { id: 'black', name: '검정', suit: '#34383e', suitDark: '#1c2027', trim: '#b8b6ae', requirement: { type: 'streak', value: 10 } }, +]; + diff --git a/constants/cutscene.ts b/constants/cutscene.ts new file mode 100644 index 0000000..26d853f --- /dev/null +++ b/constants/cutscene.ts @@ -0,0 +1,2 @@ +// Presentation only. The authoritative reveal and result timings stay in gameplay.ts. +export const CUTSCENE_TIMING = { impact: 1100, fallen: 2400, beamDissolve: 3000, complete: 3600 } as const; diff --git a/constants/gameplay.ts b/constants/gameplay.ts new file mode 100644 index 0000000..d4646b6 --- /dev/null +++ b/constants/gameplay.ts @@ -0,0 +1,14 @@ +export const ACTIONS = { + charge: { name: '으', cost: 0, effect: '생존 시 기 +1', symbol: '✦' }, + blast: { name: '파', cost: 1, effect: '상대의 으를 맞힘', symbol: '➜' }, + guard: { name: '막기', cost: 0, effect: '파를 막음', symbol: '⬡' }, + beam: { name: '에네르기파', cost: 3, effect: '으·파·막기를 맞힘', symbol: '➠' }, + dodge: { name: '순간이동', cost: 1, effect: '파·에네르기파 회피', symbol: '↝' }, +} as const; +export const ACTION_IDS = Object.keys(ACTIONS) as (keyof typeof ACTIONS)[]; +export const MATCH_COUNTDOWN_MS = 3000; +export const TURN_DURATION_MS = 4000; +export const REVEAL_DURATION_MS = 1200; +export const RULES_VERSION = '1'; +export const BOT_VERSION = '1'; +export const APP_VERSION = '0.1.0'; diff --git a/constants/home.ts b/constants/home.ts new file mode 100644 index 0000000..32a37ce --- /dev/null +++ b/constants/home.ts @@ -0,0 +1,8 @@ +import type { FighterAppearance } from '../src/shared/cosmetics.js'; + +export const HOME_TELEPORT_APPEARANCE: FighterAppearance = { + characterId: 0, + colorId: 'white', + hairStyle: 'bald', + equipment: ['shoes', 'wristbands', 'sunglasses'], +}; diff --git a/constants/legacy-data.ts b/constants/legacy-data.ts new file mode 100644 index 0000000..e3ffc20 --- /dev/null +++ b/constants/legacy-data.ts @@ -0,0 +1,4 @@ +export const LEGACY_EVENT_TYPES = [ + 'match_started', 'bot_started', 'room_created', 'room_joined', 'room_closed', + 'rematch_requested', 'request_rejected', 'connection_interrupted', 'connection_resumed', 'record_failed', +]; diff --git a/constants/ranked.ts b/constants/ranked.ts new file mode 100644 index 0000000..012fcc3 --- /dev/null +++ b/constants/ranked.ts @@ -0,0 +1,10 @@ +export const RANKED_POLL_MS = 500; +export const RANKED_QUEUE_TTL_MS = 15000; +export const RANKED_FINISHED_TTL_MS = 15 * 60 * 1000; +export const RANKED_SAVE_RETRY_MS = 1000; +export const RANKED_LEADERBOARD_LIMIT = 100; +export const PROFILE_NAME_VERSION = 1; +export const PROFILE_NAME_PREFIXES = ['푸른', '밝은', '고요한', '빛나는', '온화한', '맑은', '은빛', '씩씩한'] as const; +export const PROFILE_NAME_NOUNS = ['구름', '바람', '별빛', '노을', '하늘', '달빛', '물결', '나무'] as const; +export const PROFILE_NAME_SUFFIX_MIN = 1000; +export const PROFILE_NAME_SUFFIX_MAX = 10000; diff --git a/constants/recording.ts b/constants/recording.ts new file mode 100644 index 0000000..91c2ee1 --- /dev/null +++ b/constants/recording.ts @@ -0,0 +1,25 @@ +export const GAME_SESSION_STORAGE_KEY = 'dragon_ball_game_session_v2'; +export const GAME_CHECKPOINT_KEY = 'dragon_ball_checkpoint_v2'; +export const PENDING_RESULT_KEY = 'dragon_ball_pending_result_v2'; +export const GAME_ROOM_KEY = 'dragon_ball_room_v2'; +export const GAME_SESSION_TTL_MS = 90 * 24 * 60 * 60 * 1000; +export const RESULT_RETRY_DELAYS_MS = [2000, 5000, 10000, 30000] as const; +export const GAME_API_MESSAGES: Record = { + INVALID_SESSION: '접속 정보가 만료되었습니다. 새로 접속하세요.', + MATCH_PENDING: '이전 대전 기록을 저장한 뒤 다시 시도하세요.', + RESULT_CONFLICT: '이미 저장된 결과와 다릅니다.', + NO_ACTIVE_MATCH: '현재 대전을 확인하세요.', + ROOM_NOT_FOUND: '방 코드를 확인하세요.', + ROOM_FULL: '이미 참가자가 있는 방입니다.', + SELF_JOIN: '자신의 방에는 참가할 수 없습니다.', + ROOM_EXPIRED: '방이 만료되었습니다. 새 방을 만드세요.', + SERVER_ERROR: '처리하지 못했습니다. 다시 시도하세요.', + INVALID_PROFILE: '프로필 정보를 확인하세요.', + PROFILE_NAME_READONLY: '이름은 자동으로 지정되며 변경할 수 없습니다.', + INVALID_APPEARANCE: '캐릭터, 머리, 도복색과 장비를 확인하세요.', + COLOR_LOCKED: '아직 획득하지 않은 도복입니다.', + EQUIPMENT_LOCKED: '아직 획득하지 않은 장비입니다.', + INVALID_ACTION: '현재 선택할 수 없는 행동입니다.', + ACTION_LOCKED: '이미 선택한 행동은 바꿀 수 없습니다.', + RANKED_CLOSED: '랭킹 대전을 이용할 수 없습니다. 잠시 후 다시 시도하세요.', +}; diff --git a/constants/session.ts b/constants/session.ts new file mode 100644 index 0000000..f3b2532 --- /dev/null +++ b/constants/session.ts @@ -0,0 +1,7 @@ +export const GUEST_TOKEN_KEY = 'dragon_ball_guest_token'; +export const RECONNECT_GRACE_MS = 10000; +export const WAITING_ROOM_TTL_MS = 600000; +export const ROOM_CODE_LENGTH = 6; +export const ROOM_CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; +export const REQUEST_CACHE_LIMIT = 100; +export const RECORD_RETRY_MS = 2000; diff --git a/constants/webrtc.ts b/constants/webrtc.ts new file mode 100644 index 0000000..8cb0c77 --- /dev/null +++ b/constants/webrtc.ts @@ -0,0 +1,10 @@ +export const PROTOCOL_VERSION = 1; +export const CONNECT_TIMEOUT_MS = 15000; +export const PROTOCOL_TIMEOUT_MS = 3000; +export const PEER_PING_MS = 2000; +export const PEER_SILENCE_MS = 6000; +export const MAX_PEER_MESSAGE_BYTES = 16384; +export const ACTIVE_ROOM_TTL_MS = 24 * 60 * 60 * 1000; +export const TURN_CREDENTIAL_TTL_SECONDS = 3600; +export const RESUME_START_DELAY_MS = 100; +export const ROOM_POLL_MS = 1000; diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..0ea54c0 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,2522 @@ +{ + "name": "dragon-ball-hand-game", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "dragon-ball-hand-game", + "version": "0.1.0", + "dependencies": { + "@fastify/static": "^10.1.6", + "fastify": "^5.0.0", + "mongodb": "^6.0.0", + "socket.io": "^4.8.0", + "socket.io-client": "^4.8.0" + }, + "devDependencies": { + "@types/node": "^24.0.0", + "tsx": "^4.0.0", + "typescript": "^5.0.0", + "vite": "^7.0.0" + }, + "engines": { + "node": ">=24" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@fastify/accept-negotiator": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@fastify/accept-negotiator/-/accept-negotiator-2.1.1.tgz", + "integrity": "sha512-gO48sJ/4+P9hYE24tEoWQF4fZvCfRmStf6NFhuT5hwXkH+3JXGdemoE/7ympeQZglieeLJx256rbg8e58IrCyA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/ajv-compiler": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-4.0.7.tgz", + "integrity": "sha512-VP0yIfwlc1ohj733keYDqyHm1VWeP9VQxYomunhr90jkC4aNNibbkrRiaNgPgqj1ORxUenILR4qzA/ppYBNAtQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^4.0.0" + } + }, + "node_modules/@fastify/deepmerge": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@fastify/deepmerge/-/deepmerge-3.2.2.tgz", + "integrity": "sha512-155eqA/WMQdN6gUD4RrhS2K0Pcz2Ou9pQ86L0sipOpP9ONbTA6oYw/hW4VdBjR4egcDHqmWUK9x80oUX7S1mDQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/error": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.1.tgz", + "integrity": "sha512-MYBumuIwP+5MIpXSyKm6/o1Eigcoij186lWUssX3LH5U8KWqU0ktZhzC4CErIX9EJeQeRGI+zJZQWakBlFbUcg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/fast-json-stringify-compiler": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@fastify/fast-json-stringify-compiler/-/fast-json-stringify-compiler-5.1.1.tgz", + "integrity": "sha512-pyxRKk7UpUnnrztP9pF4+ZYxusEQ4LifTyzRdiXmyql1QezNHxO02D25MVU2o4kMeAMYWAjG6RKRlojgkbiDAw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "fast-json-stringify": "^7.0.0" + } + }, + "node_modules/@fastify/forwarded": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@fastify/forwarded/-/forwarded-3.1.1.tgz", + "integrity": "sha512-XfDtW2Icvhqi6DP1YHFK4q/E8WpNd3+raBdzAyVZxmTInbFTtnEEKvEAMMOVdFuRyv+ynYipaA9D8FvjPqACkw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/merge-json-schemas": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.2.2.tgz", + "integrity": "sha512-d/gx9sRbUCVgGRGzma46wLcwiY8noTqO2Nmjg/vbTARxHRE5bdh5PIL8s97Lt1X7ScRWRW52eVYxwIoPwIdVUA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/@fastify/proxy-addr": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/@fastify/proxy-addr/-/proxy-addr-5.1.2.tgz", + "integrity": "sha512-uqB4QsgVoro0XDC+DIftIMGXHdBPn+lK6Ek3xXAIxPMCJfqxnbtyEoMCm2KPUVOuIosd7Cyw9HawEqMFM/UA+g==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/forwarded": "^3.0.0", + "ipaddr.js": "^2.1.0" + } + }, + "node_modules/@fastify/send": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@fastify/send/-/send-4.1.2.tgz", + "integrity": "sha512-PitRBXg/N4IrE5ToLuP7lITI5Mz8MmP13dv2aMtMNfaGfc5W5hNOwHqcsdQdqu0Lwm5cpDiYLkiOsZTgmhyt2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@lukeed/ms": "^2.0.2", + "escape-html": "~1.0.3", + "fast-decode-uri-component": "^1.0.1", + "http-errors": "^2.0.0", + "mime": "^3" + } + }, + "node_modules/@fastify/static": { + "version": "10.1.6", + "resolved": "https://registry.npmjs.org/@fastify/static/-/static-10.1.6.tgz", + "integrity": "sha512-f3x9XisI+y6AIhIfA/+Q3SFsuXeqNNk5HQh5htgHsPxmMLb4pLFskV6eFaLSW3IbI4azrS5np+/tkzXNH99LyQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/accept-negotiator": "^2.0.0", + "@fastify/error": "^4.0.0", + "@fastify/send": "^4.0.0", + "content-disposition": "^3.0.0", + "fastify-plugin": "^6.0.0", + "fastq": "^1.17.1", + "glob": "^13.0.0" + } + }, + "node_modules/@lukeed/ms": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@lukeed/ms/-/ms-2.0.2.tgz", + "integrity": "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@mongodb-js/saslprep": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.6.0.tgz", + "integrity": "sha512-19jGXfpctLDJUELLUZGJ5BllcLT7rHdF+gTTaN2jzqu0bGkUhpdcYtgK2Vux+g3zuBAa0TM/W666QMIjYhYujw==", + "license": "MIT", + "dependencies": { + "sparse-bitfield": "^3.0.3" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.64.5.tgz", + "integrity": "sha512-MrfgRQSpD5hkkRg6XI16B6EDLb+6+xp8xje6bpdSwkYw+qxrBKbUVrRAQu9h82UT+QumSIVQ8HXsXjPd2dlhqw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.64.5.tgz", + "integrity": "sha512-+uu2p/R9Tzn8GpoQyX1pAo5dmKT1DhXevfaCzqzmPFhnLjHLWw7uWkaVGlxmhwYoRiYYhsGfRcD5cTBpL7rgSw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.64.5.tgz", + "integrity": "sha512-17dys+YEmzwzzFVHQAVDONg5FALXY9kd99HFEpfk4gJVHYxoh5bGea3D4t1fXXnwkTcOe25wPyd6nWS0I8IdLQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.64.5.tgz", + "integrity": "sha512-wg0zQEKkEvFZf1UaaX1UELOoPs//OSKUG7D+yITuqOsAvza7LvHTv5Wu4nmMfzNG7EYI2jp2PJC7Id6OETmsYQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.64.5.tgz", + "integrity": "sha512-CoJmWcpEMuwin+TZ/ev/H9uDyF/uxUKQSWXdmIPn/UBx/uGV9+4ebMp0FwnlhWR9PE1AdzYsKf3bZevhif38+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.64.5.tgz", + "integrity": "sha512-PxZnLAaTUAL5xG0W2BXLeYPKrK2b3LIASV2/e/Kf7PZvSLI1iAQIm9B9aJD+YyFIT7hc6qaMV3+OOO35R28LfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.64.5.tgz", + "integrity": "sha512-uPo3IvnONjhbFXwWgroBeFwOicuhT7ZwQrnzkzmOtBJzeQbkUJUE1SJDfDI31ijQ5+ZUIA6sPrdUMxTSnxUWAg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.64.5.tgz", + "integrity": "sha512-ywI8Jyuc4LyHYTjC0mw4yA1ulvZBkHusdpWnoiEBsmBuHvSnt90btTBGCZ+s/2y+YNNfVAEKy33nPo5UI9zTmg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.64.5.tgz", + "integrity": "sha512-tmuXJoQ/3ovSr637aykZMdQteU/CB2APAz2CrvsbibuoHZi1BS1Db13cVViBdEARGwXCLn1DSJ8wsjoThLJPGA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.64.5.tgz", + "integrity": "sha512-5FMjSYNcUOHx7oW8Qu9KB6jGPpvF/8sG7+BDHwU/Ihh1/6FMJ9ySmsFG4tJ1qbNRuP8OLzCBDso4sF0mQaLuFQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.64.5.tgz", + "integrity": "sha512-K8ZJ+neLDbUkIrL8dqF/5LtuVCrKwo27kREgOlxjBEjlOXwHvDnHLU8Z/UKv/B49qi7e8hsSDOyoJwhwqhvX4w==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.64.5.tgz", + "integrity": "sha512-NlVxsq7PFPv0DBKGuNWRDKixQYQCjM53zl04mxEcKMBmJbCBIUm1JdPfIWDRHyOOFzzYPmLQzfeEwh4716ayQg==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.64.5.tgz", + "integrity": "sha512-ARhRHJYs5eCFet5azfrHD97vOkD8OzNW2RSMlvo5CcBqXaTOLfp0OHDtUQh7/K5AL5fUYoFp2fgC9KDg4vVYMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.64.5.tgz", + "integrity": "sha512-atSgreM5C7jpxd9aEXN+6vJ5wTd05iYw+S3FiQpw7VsoEl8B90llbq35ARON+GOHj5QlVQEq2vOKWCHTob/BSQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.64.5.tgz", + "integrity": "sha512-vmlmwBFGnjVWxSRoVabNkyYuscSmg52Pd0fM8jEbNT3Jmka4A6HcB84x4uAmuumznWfBVaxKOqU6jbEgomWKCA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.64.5.tgz", + "integrity": "sha512-hhJrP6S9WkbsbcHgUkvCBS5Cn8IdsG8XjlLZyxjnv9+NdoYfbbZE4TWJYr8apdaj1M5t5Y1id2StnEslxpNFyA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.64.5.tgz", + "integrity": "sha512-NNVCyN9pGrwFmQa4bf+Cnf5aVH3g6ZpnlOg1Cs/H9swIHT9bRixijfzUqyEIWm1WtiAuR1LJ+rJH6zpBm8kgQA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.64.5.tgz", + "integrity": "sha512-dR9nd6OKnvSS60Ifz/L7Mj5zzYyoqyRn2NwTuaOONKaa3xsVGXyp8YusMbvFyEOR94DxnwgcbgsFqkHd29ZSzQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.64.5.tgz", + "integrity": "sha512-1tzsMjaqhsh3bAO6SJiJqh8hIr9sSPRd+i2vPPxJHod8/XYnf9TEnK0z/u3r1z1AkI347X0KbRSg9dWcxeGahw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.64.5.tgz", + "integrity": "sha512-01mWMQblKoXXsmk28KXtpbA96w7g/aOWUoV3AC16fdHdqkykGalqyyM+5u8M7uP5/MEMiiPv69Hv7iv/hEH/dA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.64.5.tgz", + "integrity": "sha512-trdnMPb/DNm4V/CgsaoGgr90HfQQ071wfK0KoIs4YpLvjBgVQGXhGabg2Z6nK4EcYNilrb9ZZPcsP6+zpJIdxQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.64.5.tgz", + "integrity": "sha512-x2LxZlfdfsw99T6/qb9EqE/Qi84JWzibjyOJyhJsgFJy6CafaTiqIXCCnslCGrV5XWNh7fwrS3FbRENcuKnooA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.64.5.tgz", + "integrity": "sha512-/Qd/9aDQIo0+jmItgBmPIs8195YVbDxRRApn47GyufgbutCDOh7eP5vSKKlI4QNFeaI+wKQAkSMsPnF3EuMpaA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.64.5.tgz", + "integrity": "sha512-ngUJ/XvOWrqbd2IMsfXfh2xXFLTP3sY7sCKvxbGvYdBZ1KmXFd3ewDZ9n3/GNzAUgzRuhLT/UvJRivbNoAQRBw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.64.5.tgz", + "integrity": "sha512-IhV6ew/DuzUWwHaYmlh7MrPCV3lKFUq/amTEwysi4r/mwNPAEfAMh5dYWeTSPTeMy2pFHGMEWYtPkHzfX3tG5A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@socket.io/component-emitter": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", + "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", + "license": "MIT" + }, + "node_modules/@types/cors": { + "version": "2.8.19", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", + "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.19.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.2.tgz", + "integrity": "sha512-93d49wCWJkCyN87MxLpNN6KXPCwFw7bVUg79mGUV1OS40JGWwVCr8n61DXCSqz7CV6/VfPkb7xec7uP2M+QHWQ==", + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/@types/webidl-conversions": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz", + "integrity": "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==", + "license": "MIT" + }, + "node_modules/@types/whatwg-url": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-11.0.5.tgz", + "integrity": "sha512-coYR071JRaHa+xoEvvYqvnIHaVqaYrLPbsufM9BF63HkwI5Lgmy2QR8Q5K/lYDYo5AK82wOvSOS0UsLTpTG7uQ==", + "license": "MIT", + "dependencies": { + "@types/webidl-conversions": "*" + } + }, + "node_modules/@types/ws": { + "version": "8.18.2", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.2.tgz", + "integrity": "sha512-67MQl+fpWKVTT1NYdnmo3U4sc/xPo/zQBncVnI74qmQa0z/b+1g6iYqNmGCPbxO+zz2aklb08a0oHfegiVd0/w==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/abstract-logging": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", + "integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==", + "license": "MIT" + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv/node_modules/fast-uri": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", + "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/avvio": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/avvio/-/avvio-9.3.1.tgz", + "integrity": "sha512-QyTKW61/qlba+cUQBm/sT9X8lri4UdomqSDwCnrnj1IvLVDRRiXRkz4CgBRWB+/cmfC7SK5WQc8T1vSdGvaUcQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/error": "^4.0.0", + "fastq": "^1.17.1" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/bson": { + "version": "6.10.4", + "resolved": "https://registry.npmjs.org/bson/-/bson-6.10.4.tgz", + "integrity": "sha512-WIsKqkSC0ABoBJuT1LEX+2HEvNmNKKgnTAyd0fL8qzK4SH2i9NXg+t08YtdZp/V9IZ33cxe3iV4yM0qg8lMQng==", + "license": "Apache-2.0", + "engines": { + "node": ">=16.20.1" + } + }, + "node_modules/content-disposition": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-3.0.0.tgz", + "integrity": "sha512-ZH/0Xs9rMIFWCOmGdmS9eHBTF62qqQYNz4nVjQhkdIO/a0fCP4UIM3mRz/wiqL0L14YgAz/1xio4OaSY4+ON/A==", + "license": "MIT", + "engines": { + "node": ">=22" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cookie": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-2.0.1.tgz", + "integrity": "sha512-yuToqVvRrj6pfDXREyQAAv8SkAEk/8GS3jQRTiUMm66TVtBYmqQeoEjL2Lmq8Rpo6271vH76InTChTitEAm65w==", + "license": "MIT", + "engines": { + "node": ">=22" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/engine.io": { + "version": "6.6.11", + "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.11.tgz", + "integrity": "sha512-z34mGpLoY/82ZbkX1LqGMIaf3wjVMPL6gVC681QDqnmzJ26synI8qAE32P+a7UazmpJZ8UGrFIeAavgRfXzYrg==", + "license": "MIT", + "dependencies": { + "@types/cors": "^2.8.12", + "@types/node": ">=10.0.0", + "@types/ws": "^8.5.12", + "accepts": "~1.3.4", + "cookie": "~0.7.2", + "cors": "~2.8.5", + "debug": "~4.4.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.21.0" + }, + "engines": { + "node": ">=10.2.0" + } + }, + "node_modules/engine.io-client": { + "version": "6.6.7", + "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.7.tgz", + "integrity": "sha512-GyAvmPA7gnRCpDQbYACAy79Lg4ewevrmaPzySZz0OVvgyZc3gEfLLqPTYk8wVBScjOEpM9ZO4KryyFpVItjGEA==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.21.0", + "xmlhttprequest-ssl": "~2.1.1" + } + }, + "node_modules/engine.io-parser": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", + "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/engine.io/node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/fast-decode-uri-component": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", + "integrity": "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-json-stringify": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-7.1.0.tgz", + "integrity": "sha512-wAhAQ+es0aH+x5OofdCHlZZFdqwtiM6lpEosaRIwfg271WYeEQ/tPcXdu5vpzlqZpbJ4WNqaW4QR5UjfG4Dc2w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/merge-json-schemas": "^0.2.0", + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^4.0.0", + "json-schema-ref-resolver": "^3.0.0", + "rfdc": "^1.2.0" + } + }, + "node_modules/fast-querystring": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz", + "integrity": "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==", + "license": "MIT", + "dependencies": { + "fast-decode-uri-component": "^1.0.1" + } + }, + "node_modules/fast-uri": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-4.2.2.tgz", + "integrity": "sha512-EwVpUMzLVNOmcWW0zi5G3JJwkopB6XXjnVvNBGfqrMMsVew2hkQWl5e/pe/CfYUeS6jt4cIO2MnamEW7v/7wPw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fastify": { + "version": "5.12.5", + "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.12.5.tgz", + "integrity": "sha512-OB2k1dlxs5/NAABqeKV2FUHkSD2BbENsCak8yULVcymn3fHIPDVa9TI3SDnJSWYSllZmSYuZXy2gTnsT+Sut1A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/ajv-compiler": "^4.0.5", + "@fastify/error": "^4.0.0", + "@fastify/fast-json-stringify-compiler": "^5.0.0", + "@fastify/proxy-addr": "^5.0.0", + "abstract-logging": "^2.0.1", + "avvio": "^9.0.0", + "fast-json-stringify": "^7.0.0", + "find-my-way": "^9.6.0", + "light-my-request": "^6.0.0", + "pino": "^9.14.0 || ^10.1.0", + "process-warning": "^5.1.0", + "rfdc": "^1.3.1", + "secure-json-parse": "^4.0.0", + "semver": "^7.6.0", + "toad-cache": "^3.7.0" + } + }, + "node_modules/fastify-plugin": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-6.0.1.tgz", + "integrity": "sha512-4wW4RxSmvPXLrh5mgQEiu43qU3bw44N2ss4FTkTkQIbprUhE+/s43QX0j/ttcztdWK7PEkHqVthdc5GdEGDvjA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.3.tgz", + "integrity": "sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw==", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/find-my-way": { + "version": "9.10.0", + "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.10.0.tgz", + "integrity": "sha512-eru+uCPQ2g+nLBkf84MHP/KyNNMar0BDy0j+i8Fb72GF9/YHBZ58q7FODUtfVV7XU9nfRUHXSmaM8NmZcIbZ4Q==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-querystring": "^1.0.0", + "safe-regex2": "^5.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.5.0.tgz", + "integrity": "sha512-aq+t5NAc+cS6rZQQVWC2x98CPqGtKKTMDd4Gaodv0wShnItdKg/51djkGJ1hqH+Oy0ivDftCbSLCQob8zso01w==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/json-schema-ref-resolver": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.1.tgz", + "integrity": "sha512-w+HwgljXpVwZ3IidoUR1qPZxSOQ+NRQWuVe6a5pe8XbHsNZE4UAwVPUvzZagn7yBKbJtt6UJnRFYbNTRPZI45Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/light-my-request": { + "version": "6.8.0", + "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.8.0.tgz", + "integrity": "sha512-5Q8PjoSEdtGIB8fikZsTem5vgRo4F2mR6vUKTDW/H1g6MtAt8e3duQbg/QxL5yzg6BZhor2KOUm3hhWMWLTZwg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause", + "dependencies": { + "@fastify/deepmerge": "^3.1.0", + "cookie": "^2.0.0", + "process-warning": "^5.0.0", + "set-cookie-parser": "^3.0.1" + } + }, + "node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/memory-pager": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz", + "integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==", + "license": "MIT" + }, + "node_modules/mime": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", + "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/mongodb": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.21.0.tgz", + "integrity": "sha512-URyb/VXMjJ4da46OeSXg+puO39XH9DeQpWCslifrRn9JWugy0D+DvvBvkm2WxmHe61O/H19JM66p1z7RHVkZ6A==", + "license": "Apache-2.0", + "dependencies": { + "@mongodb-js/saslprep": "^1.3.0", + "bson": "^6.10.4", + "mongodb-connection-string-url": "^3.0.2" + }, + "engines": { + "node": ">=16.20.1" + }, + "peerDependencies": { + "@aws-sdk/credential-providers": "^3.188.0", + "@mongodb-js/zstd": "^1.1.0 || ^2.0.0", + "gcp-metadata": "^5.2.0", + "kerberos": "^2.0.1", + "mongodb-client-encryption": ">=6.0.0 <7", + "snappy": "^7.3.2", + "socks": "^2.7.1" + }, + "peerDependenciesMeta": { + "@aws-sdk/credential-providers": { + "optional": true + }, + "@mongodb-js/zstd": { + "optional": true + }, + "gcp-metadata": { + "optional": true + }, + "kerberos": { + "optional": true + }, + "mongodb-client-encryption": { + "optional": true + }, + "snappy": { + "optional": true + }, + "socks": { + "optional": true + } + } + }, + "node_modules/mongodb-connection-string-url": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-3.0.2.tgz", + "integrity": "sha512-rMO7CGo/9BFwyZABcKAWL8UJwH/Kc2x0g72uhDWzG48URRax5TCIcJ7Rc3RZqffZzO/Gwff/jyKwCU9TN8gehA==", + "license": "Apache-2.0", + "dependencies": { + "@types/whatwg-url": "^11.0.2", + "whatwg-url": "^14.1.0 || ^13.0.0" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pino": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/pino/-/pino-10.4.0.tgz", + "integrity": "sha512-bk1ZMTwG/Vymx+zPoa28MhNzucFvXJWp3csrSuxuHPSMeUettaCoQ5Jvg07mtC4JGNpcfTwz41oVCnNgBK9+vA==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^1.0.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^4.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", + "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, + "node_modules/postcss": { + "version": "8.5.29", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.29.tgz", + "integrity": "sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.19", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/process-warning": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.1.1.tgz", + "integrity": "sha512-oCkNVj4OKnKEcocVBcDfiIugN1/cE6rgfT3nCtzeqZPA8+rIxMaQ0xXzIkGP8Gj2qUaP1UR7uhJ1VD8lUCpzCg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, + "node_modules/real-require": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-1.0.0.tgz", + "integrity": "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==", + "license": "MIT" + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ret": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.5.0.tgz", + "integrity": "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "license": "MIT" + }, + "node_modules/rollup": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.64.5.tgz", + "integrity": "sha512-EAP5ZHcz2ijbgCWwPkpI7zh6bfsri2Ez5FtCy2dCp1k62duXzfG7DLGGh7RU2QQXWwavT+u4qpvDF/TWOiiGTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.64.5", + "@rollup/rollup-android-arm64": "4.64.5", + "@rollup/rollup-darwin-arm64": "4.64.5", + "@rollup/rollup-darwin-x64": "4.64.5", + "@rollup/rollup-freebsd-arm64": "4.64.5", + "@rollup/rollup-freebsd-x64": "4.64.5", + "@rollup/rollup-linux-arm-gnueabihf": "4.64.5", + "@rollup/rollup-linux-arm-musleabihf": "4.64.5", + "@rollup/rollup-linux-arm64-gnu": "4.64.5", + "@rollup/rollup-linux-arm64-musl": "4.64.5", + "@rollup/rollup-linux-loong64-gnu": "4.64.5", + "@rollup/rollup-linux-loong64-musl": "4.64.5", + "@rollup/rollup-linux-ppc64-gnu": "4.64.5", + "@rollup/rollup-linux-ppc64-musl": "4.64.5", + "@rollup/rollup-linux-riscv64-gnu": "4.64.5", + "@rollup/rollup-linux-riscv64-musl": "4.64.5", + "@rollup/rollup-linux-s390x-gnu": "4.64.5", + "@rollup/rollup-linux-x64-gnu": "4.64.5", + "@rollup/rollup-linux-x64-musl": "4.64.5", + "@rollup/rollup-openbsd-x64": "4.64.5", + "@rollup/rollup-openharmony-arm64": "4.64.5", + "@rollup/rollup-win32-arm64-msvc": "4.64.5", + "@rollup/rollup-win32-ia32-msvc": "4.64.5", + "@rollup/rollup-win32-x64-gnu": "4.64.5", + "@rollup/rollup-win32-x64-msvc": "4.64.5", + "fsevents": "~2.3.2" + } + }, + "node_modules/safe-regex2": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.1.2.tgz", + "integrity": "sha512-tAtCGFPPP02VroDVzp4daGi6bC7jWG+41D47GKf18FeH/ubvKQHT9VmhAkvtjNOmJwqtApzWeUpEhkyEYULvmw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ret": "~0.5.0" + }, + "bin": { + "safe-regex2": "bin/safe-regex2.js" + } + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/secure-json-parse": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.1.tgz", + "integrity": "sha512-lnd81MQEZKjhW/Gd2TQqdd4XP6QYNdDW/EP+I7nTrFHP9y2LmaGPIHk9xsnzcOiEr1c39c/e84xFgGviQKBTRw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/set-cookie-parser": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.3.tgz", + "integrity": "sha512-xletSdAI5efyVJjsatBbzDkXl3M/Zmad3xfnQe7ZXuBB0yNwW7C2WFsNGjdRNfZunmNHQuX79xs/ACMTLCdcew==", + "license": "MIT" + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/socket.io": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.4.tgz", + "integrity": "sha512-9ZV9zg8vN8hO3Y25DhR7nUkJ1VDnBhq5O/JrXhFT9s6+F0gqytSASTHVfCFc2mJXaTfARroErplXlzFWwETZqQ==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.4", + "cors": "~2.8.5", + "debug": "~4.4.1", + "engine.io": "~6.6.0", + "socket.io-adapter": "~2.5.2", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.2.0" + } + }, + "node_modules/socket.io-adapter": { + "version": "2.5.8", + "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.8.tgz", + "integrity": "sha512-6Oy52pbg+kvdCVvjcN+FnY7BvxZ7cIHNScbvztT/It5d0vbwoJoVZmF2gjJmnV0/4WlXRfG15zc45ySk9Ah8bw==", + "license": "MIT", + "dependencies": { + "debug": "~4.4.1", + "ws": "~8.21.0" + } + }, + "node_modules/socket.io-client": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.4.tgz", + "integrity": "sha512-7wjKAvVXYOJsbmUgkWbnhPD4bFpkqrUR0++XOjpGFmqDqsqV1CI93VTraeExLxpjxEqrWgFoSRZu1ihjLWfkAA==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1", + "engine.io-client": "~6.6.1", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-parser": { + "version": "4.2.7", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.7.tgz", + "integrity": "sha512-IH/iSeO9T6gz1KkFleGDWkG9N3dl4jXVYUtMhIqH10Md0ttMer8nUNWiP1DKuNrybD2xBrixLJdCC9J6ECoYkg==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sparse-bitfield": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz", + "integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==", + "license": "MIT", + "dependencies": { + "memory-pager": "^1.0.2" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/thread-stream": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz", + "integrity": "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==", + "license": "MIT", + "dependencies": { + "real-require": "^1.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/toad-cache": { + "version": "3.7.4", + "resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.4.tgz", + "integrity": "sha512-m1TdR/rvT7kgGJZhspNtXdsdYk0fddFpJJFlG5s+UkPFo6lkLoZ3YLOaovPYjq1R75NP5JfeTlSHaOsE09peCg==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx": { + "version": "4.23.15", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.15.tgz", + "integrity": "sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "license": "MIT" + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vite": { + "version": "7.3.7", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.7.tgz", + "integrity": "sha512-AHEzTORMbi7/7aXp7w7KZ5+DCUdSeLsJz0grY8xHpc9DHdYb0vQu6DqJfm2tOw3gDExNgPgnJTXxM6syYFHaCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0 || ^0.28.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/ws": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xmlhttprequest-ssl": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz", + "integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==", + "engines": { + "node": ">=0.4.0" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..876a332 --- /dev/null +++ b/package.json @@ -0,0 +1,29 @@ +{ + "name": "dragon-ball-hand-game", + "version": "0.1.0", + "private": true, + "type": "module", + "engines": { + "node": ">=24" + }, + "scripts": { + "dev": "node --env-file-if-exists=.env --import tsx --watch src/server/index.ts", + "start": "node --env-file-if-exists=.env dist/server/src/server/index.js", + "typecheck": "tsc --noEmit", + "build": "tsc -p tsconfig.server.json && vite build", + "test": "tsx --test tests/*.test.ts" + }, + "dependencies": { + "@fastify/static": "^10.1.6", + "fastify": "^5.0.0", + "mongodb": "^6.0.0", + "socket.io": "^4.8.0", + "socket.io-client": "^4.8.0" + }, + "devDependencies": { + "@types/node": "^24.0.0", + "tsx": "^4.0.0", + "typescript": "^5.0.0", + "vite": "^7.0.0" + } +} diff --git a/scripts/cleanup-legacy-data.ts b/scripts/cleanup-legacy-data.ts new file mode 100644 index 0000000..c3311d0 --- /dev/null +++ b/scripts/cleanup-legacy-data.ts @@ -0,0 +1,43 @@ +import { mkdir, writeFile } from 'node:fs/promises'; +import { dirname, resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { BSON, MongoClient, type Db } from 'mongodb'; +import { LEGACY_EVENT_TYPES } from '../constants/legacy-data.js'; + +// Run while the old app is stopped. Never called by server startup. +export async function cleanupLegacyData(db: Db, backupPath?: string) { + const matchesExist = (await db.listCollections({ name: 'matches' }).toArray()).length > 0; + const matches = await db.collection('matches').find().toArray(); + const events = await db.collection('analytics_events').find({ source: { $ne: 'client' }, eventType: { $in: LEGACY_EVENT_TYPES } }).toArray(); + const samples = await db.collection('analytics_presence_samples').find({ source: { $exists: false } }).toArray(); + const players = await db.collection('players').find({ firstMatchAt: { $exists: true } }, { projection: { _id: 1, firstMatchAt: 1 } }).toArray(); + const counts = { matches: matches.length, events: events.length, samples: samples.length, playerFields: players.length }; + if (!backupPath) return { applied: false, database: db.databaseName, counts }; + + // Persist BSON types and indexes before the first mutation. Refuse overwriting a backup. + const backup = { database: db.databaseName, createdAt: new Date(), matches, events, samples, players, + matchIndexes: matchesExist ? await db.collection('matches').indexes() : [] }; + await mkdir(dirname(backupPath), { recursive: true }); + await writeFile(backupPath, BSON.EJSON.stringify(backup, { relaxed: false }), { flag: 'wx' }); + if (matchesExist) { + if (await db.collection('matches').countDocuments() !== matches.length) throw new Error('MATCHES_CHANGED_STOP_OLD_SERVER'); + await db.collection('matches').drop(); + } + if (events.length) await db.collection('analytics_events').deleteMany({ _id: { $in: events.map(e => e._id) }, source: { $ne: 'client' }, eventType: { $in: LEGACY_EVENT_TYPES } }); + if (samples.length) await db.collection('analytics_presence_samples').deleteMany({ _id: { $in: samples.map(s => s._id) }, source: { $exists: false } }); + if (players.length) await db.collection('players').updateMany({ _id: { $in: players.map(p => p._id) } }, { $unset: { firstMatchAt: '' } }); + return { applied: true, database: db.databaseName, counts, backupPath }; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + const args = process.argv.slice(2), apply = args.includes('--apply'); + const database = args[args.indexOf('--database') + 1]; + const configured = process.env.MONGODB_DATABASE || 'dragon_ball'; + if (!args.includes('--database') || database !== configured || ['admin', 'config', 'local'].includes(database)) throw new Error('Specify --database matching MONGODB_DATABASE'); + if (!process.env.MONGODB_URI) throw new Error('MONGODB_URI_REQUIRED'); + const backup = args.includes('--backup') ? args[args.indexOf('--backup') + 1] : undefined; + if (apply && (!backup || backup.startsWith('--'))) throw new Error('--apply requires --backup '); + const client = new MongoClient(process.env.MONGODB_URI, { serverSelectionTimeoutMS: 5000 }); + try { await client.connect(); console.log(JSON.stringify(await cleanupLegacyData(client.db(database), apply ? resolve(backup!) : undefined), null, 2)); } + finally { await client.close(); } +} diff --git a/src/client/action-renderer.ts b/src/client/action-renderer.ts new file mode 100644 index 0000000..6db3abe --- /dev/null +++ b/src/client/action-renderer.ts @@ -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 | 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('[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('[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, + }; +} diff --git a/src/client/admin.css b/src/client/admin.css new file mode 100644 index 0000000..9d1db0b --- /dev/null +++ b/src/client/admin.css @@ -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}} diff --git a/src/client/admin.ts b/src/client/admin.ts new file mode 100644 index 0000000..aad9021 --- /dev/null +++ b/src/client/admin.ts @@ -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('.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('[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(); diff --git a/src/client/admin/analytics/index.html b/src/client/admin/analytics/index.html new file mode 100644 index 0000000..b6ada56 --- /dev/null +++ b/src/client/admin/analytics/index.html @@ -0,0 +1,6 @@ +방문·게임 분석 +

방문·게임 분석

익명 브라우저 기준 · Asia/Seoul
+
+

+ +
diff --git a/src/client/analytics.ts b/src/client/analytics.ts new file mode 100644 index 0000000..896720e --- /dev/null +++ b/src/client/analytics.ts @@ -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[]): Promise; activity(visible: boolean): Promise } +export function createClientAnalytics(transport: AnalyticsTransport) { + const queue = new Map; at: number; attempts: number }>(); + let sending = false, pageSent = false, enabled = false, destroyed = false, lastVisible = -Infinity, lastHidden = -Infinity; + function event(eventType: string, fields: Record = {}) { + 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(); }, + }; +} diff --git a/src/client/api.ts b/src/client/api.ts new file mode 100644 index 0000000..8fda932 --- /dev/null +++ b/src/client/api.ts @@ -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(path: string, method: 'GET' | 'POST' = 'GET', body?: unknown, authenticated = true): Promise { + const headers: Record = { '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; + 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('/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('/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('/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('/api/game-session/matches', 'POST', input); + session().activeMatch = active; persist(); return active; + }, + async record(input: ResultSubmission) { + const ack = await request('/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; diff --git a/src/client/fighter-art.ts b/src/client/fighter-art.ts new file mode 100644 index 0000000..4b39222 --- /dev/null +++ b/src/client/fighter-art.ts @@ -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 ` + ${bald ? '' : whiteHair ? '' : ''} + ${includeNeck ? '\n ' : ''} + + + ${bald ? '' : ''} + + + ${bald ? '' : whiteHair ? '' : ''} + ${veteran ? '' : ''} + ${headband ? '' : ''} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ${eyewear ? ` + + + + ` : ''} + `; +} + +/** 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 ``; +} + +/** Preserved Yamcha-reference variant; not used by the active finisher. */ +export function yamchaFallenFighterArt(index: number) { + return ``; +} + +/** 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 ``; +} + +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 ``; +} + +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 ``; + }).join(''); + return ``; +} + +export function ashPileArt() { + return ``; +} + +export function actionIcon(action: ActionId) { + const shapes: Record = { + charge: '', + blast: '', + guard: '', + beam: '', + dodge: '', + }; + return ``; +} + +export const checkIcon = ''; diff --git a/src/client/finish-cutscene.ts b/src/client/finish-cutscene.ts new file mode 100644 index 0000000..3f48202 --- /dev/null +++ b/src/client/finish-cutscene.ts @@ -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 | 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('.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)}`; + artSignature = signature; + dialog.querySelector('.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); }, + }; +} diff --git a/src/client/fonts/BlackHanSans-Regular.ttf b/src/client/fonts/BlackHanSans-Regular.ttf new file mode 100644 index 0000000..e0d7584 Binary files /dev/null and b/src/client/fonts/BlackHanSans-Regular.ttf differ diff --git a/src/client/fonts/OFL.txt b/src/client/fonts/OFL.txt new file mode 100644 index 0000000..3b9b3c4 --- /dev/null +++ b/src/client/fonts/OFL.txt @@ -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. diff --git a/src/client/friend-match.ts b/src/client/friend-match.ts new file mode 100644 index 0000000..4c638d0 --- /dev/null +++ b/src/client/friend-match.ts @@ -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; +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(); + const clockReplies = new Map(); + 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) { void Promise.resolve().then(fn).catch(() => { if (!stopped) abort('protocol_error'); }); } + function send(type: PeerMessage['type'], fields: Record = {}) { + 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((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) { + 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((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(); }, + }; +} diff --git a/src/client/game.css b/src/client/game.css new file mode 100644 index 0000000..e7a428f --- /dev/null +++ b/src/client/game.css @@ -0,0 +1,170 @@ +/* Player entry only: the analytics app keeps its existing stylesheet. */ +:root{font-family:"Arial Rounded MT Bold","Segoe UI","Malgun Gothic",sans-serif;color:#223c3c;background:#f7faf5;font-synthesis:none;--ink:#223c3c;--muted:#647975;--mint:#66d6b0;--lime:#d5f77c;--sky:#a6e4fa;--line:#d9e5de;--green:#4d9b18} +*{box-sizing:border-box}body{margin:0}button,input{font:inherit}button{position:relative;cursor:pointer;border:2px solid var(--line);border-bottom-width:5px;background:#fff;color:var(--ink);border-radius:18px;min-height:50px;padding:12px 20px;font-weight:800;transition:transform .12s,background .12s,border-color .12s;touch-action:manipulation;-webkit-tap-highlight-color:transparent}button:hover:not(:disabled){background:#f1f8ec;border-color:#abc5ae;transform:translateY(-2px)}button:active:not(:disabled){transform:translateY(3px);border-bottom-width:2px}button:disabled{cursor:default}button.primary{background:#a8e94e;border-color:#76b52e;color:#254606}button.primary:hover:not(:disabled){background:#b8f268;border-color:#69a527}button.primary:disabled{background:#e3edda;border-color:#cbd9c0;color:#637758}button:focus-visible,a:focus-visible,input:focus-visible{outline:3px solid #266acc;outline-offset:4px}.quiet{border:0;background:transparent;color:#536c65;padding:10px 12px;min-height:42px;font-size:13px;box-shadow:none}.quiet:hover:not(:disabled){background:#e9f2e9;transform:none}.quiet:active:not(:disabled){margin:0;transform:none}[hidden]{display:none!important}.app{max-width:1100px;margin:auto;padding:0 36px;min-height:100svh;display:flex;flex-direction:column}header{height:80px;display:flex;align-items:center;justify-content:space-between;gap:16px}.brand{display:flex;align-items:center;gap:10px;color:var(--ink);text-decoration:none;font-size:20px;font-weight:900;letter-spacing:-1px}.brand-ball{width:34px;height:34px}.header-tools{display:flex;align-items:center;gap:14px}#connection{font-size:11px;color:var(--muted);display:flex;align-items:center;gap:6px}#connection:before{content:'';width:7px;height:7px;border-radius:50%;background:#adbeb8}#connection.connected:before{background:#6ab82b}main{flex:1;display:flex;flex-direction:column;justify-content:center;padding:24px 0}.home{display:grid;grid-template-columns:1.25fr 1fr;gap:44px;align-items:center;width:100%}.home-stage{isolation:isolate;position:relative;min-height:460px;background:var(--mint);border-radius:40px;overflow:hidden;display:flex;flex-direction:column;justify-content:space-between;padding-top:32px}.home-stage:before{content:'';position:absolute;width:400px;height:400px;border:44px solid #8fe4b7;border-radius:50%;left:-140px;top:150px;z-index:-1}.home-stage:after{content:'';position:absolute;width:230px;height:500px;background:var(--lime);transform:rotate(25deg);right:-130px;top:-120px;z-index:-1}.home h1{text-align:center;font-size:clamp(38px,4.4vw,58px);line-height:1.1;letter-spacing:-3px;font-weight:950;margin:12px 0 0;color:#174b3e;transform:rotate(-4deg)}#home-fighters{position:relative;display:flex;justify-content:center;align-items:flex-end;padding:40px 8px 38px;z-index:2}.home-fighter{width:51%;flex-shrink:0;position:relative}.home-fighter:first-child{transform:rotate(-7deg) translateX(4px);z-index:2}.home-fighter:last-child{transform:scaleX(-1) rotate(-7deg) translateX(4px)}.home-fighter .character{animation:idle 3s ease-in-out infinite}.home-fighter:last-child .character{animation-delay:-1.5s}.home-versus{position:absolute;top:37%;left:50%;transform:translate(-50%,-50%) rotate(-8deg);font-size:32px;font-weight:950;font-style:italic;color:#fff;text-shadow:0 3px 0 #328b6e;z-index:4}.home-platform{position:absolute;bottom:-25px;left:-20%;width:140%;height:150px;border-radius:50%;background:#40b98e;transform:rotate(-7deg);border-top:14px solid #b9f18c}.home-menu{width:100%;padding:16px 0}.home-actions{display:grid;gap:16px}.home-actions>.primary{display:flex;align-items:center;justify-content:space-between;font-size:21px;min-height:74px;border-radius:20px;padding:18px 24px}.home-actions svg{width:26px;height:26px}.split{display:flex;gap:12px}.split>*{flex:1;min-width:0}.home-actions .split button{min-height:62px;padding:12px 8px}.stats{display:flex;margin-top:30px;border-top:2px solid var(--line);padding-top:23px}.stat-mode{flex:1;padding:0 22px}.stat-mode:first-child{padding-left:4px}.stat-mode+.stat-mode{border-left:2px solid var(--line)}.stat-mode>span{font-size:13px;font-weight:800;color:#526d61}.stat-mode>div{display:flex;gap:24px;margin-top:10px;font-size:11px;color:#657b6b}.stat-mode strong{display:block;font-size:30px;line-height:1.4;color:#284839;font-variant-numeric:tabular-nums}.join-form{margin-top:24px;text-align:left}.join-form label{display:block;font-size:13px;font-weight:800;margin-bottom:8px}.join-form>div{display:flex;gap:8px}.join-form input{min-width:0;flex:1}.join-form>.quiet{display:block;margin:8px auto 0}input{border:2px solid #c6d8c8;border-radius:14px;min-height:50px;background:#fff;color:var(--ink);padding:12px}#room-code{font-size:22px;letter-spacing:5px;font-family:monospace;text-transform:uppercase}.waiting{width:min(100%,440px);margin:auto;text-align:center}.waiting h1{font-size:26px;letter-spacing:-1px;display:flex;justify-content:center;align-items:center;gap:12px}#waiting-fighter{width:210px;margin:0 auto -8px;background:var(--mint);border-radius:50%}.waiting-dots{display:flex;gap:4px}.waiting-dots i{width:5px;height:5px;border-radius:50%;background:#5c8a50;animation:waiting 1s infinite alternate}.waiting-dots i:nth-child(2){animation-delay:.2s}.waiting-dots i:nth-child(3){animation-delay:.4s}.room-code{font-family:monospace;font-size:44px;letter-spacing:8px;background:#e8f6d6;border:2px dashed #a2c781;border-radius:20px;padding:24px 12px;margin:24px 0}.subtle{font-size:12px;color:var(--muted)}.notice{max-width:680px;text-align:center;margin:18px auto 0;color:#9c442d;font-size:13px;line-height:1.7}.notice:empty{display:none}#reconnect{margin:14px auto;width:max-content}footer{display:flex;justify-content:center;padding:6px 0 12px}footer .quiet{font-size:11px;color:#718577}body[data-screen=match] footer,body[data-screen=result] footer{padding:0}body[data-screen=match] main,body[data-screen=result] main{justify-content:flex-start;padding-top:8px} +/* The arena is the primary surface, not a card surrounding small avatars. */ +#match{width:100%;max-width:920px;margin:auto}.match-top{display:flex;align-items:center;justify-content:space-between;margin-bottom:14px}.match-title{display:flex;align-items:center;gap:14px}.mode-label{font-size:12px;font-weight:800;color:#557363;background:#e4eedb;border-radius:8px;padding:7px 10px}.turn-heading{font-size:25px;letter-spacing:-1px;margin:0}.leave{border:2px solid #d9e5de;border-radius:13px;min-height:38px;font-size:12px}.timing{position:relative;display:flex;justify-content:space-between;align-items:flex-start;min-height:48px;font-size:13px;font-weight:800;padding-bottom:22px}.time-label{font-size:17px;font-variant-numeric:tabular-nums;line-height:1}.time-track{position:absolute;bottom:10px;left:0;right:0;height:10px;background:#e1eade;border-radius:10px;overflow:hidden}.time-track span{display:block;height:100%;width:100%;transform:scaleX(var(--time,1));transform-origin:left;background:#82ca35;border-radius:10px;transition:transform .1s linear}.timing[data-urgent=true]{color:#c65c31}.timing[data-urgent=true] .time-track span{background:#ef915b}#match[data-locked=true] .time-track span{background:#55b8bd}.arena{isolation:isolate;position:relative;display:flex;justify-content:space-between;align-items:flex-end;overflow:hidden;min-height:360px;padding:20px 38px 18px;background:var(--mint);border-radius:30px}.arena:before{content:'';position:absolute;inset:-40% -30% -10% 52%;background:var(--sky);transform:rotate(13deg);z-index:-2;border-left:10px solid #ffffff36}.arena:after{content:'';position:absolute;left:-15%;top:40%;width:62%;height:90%;border:32px solid #b9f19370;border-radius:50%;z-index:-2}.arena-floor{position:absolute;bottom:-20%;left:-10%;width:120%;height:47%;border-radius:50%;background:#258f7b20;border-top:3px solid #ffffff60;z-index:-1}.fighter{position:relative;width:43%;text-align:center;--direction:1;--fx:#fff4b1;z-index:2}.fighter[data-fighter="1"]{--direction:-1;--fx:#f3fdff}.fighter-info{display:flex;justify-content:center;align-items:center;gap:10px;position:relative;z-index:6;height:38px}.fighter-name{font-size:14px;font-weight:900;white-space:nowrap;background:#fff9;border-radius:11px;padding:6px 13px}.fighter.self .fighter-name{background:#214c42;color:#fff}.energy{display:flex;align-items:center;gap:3px;background:#fffffff0;border:2px solid #ffffff80;border-radius:12px;padding:3px 10px 3px 6px;position:relative;min-width:64px}.energy>svg{width:24px;height:24px;color:#628c23}.energy-count{font-size:20px;line-height:1.2;font-variant-numeric:tabular-nums}.energy-change{position:absolute;right:-30px;top:0;font-size:24px;font-weight:950;color:#245938;text-shadow:0 2px #fff;z-index:8}.avatar{position:relative;width:min(100%,300px);margin:8px auto 0;aspect-ratio:260/300}.character-direction{height:100%;position:relative;z-index:2}.fighter[data-fighter="1"] .character-direction{transform:scaleX(-1)}.fighter-art{width:100%;height:100%;display:block;overflow:visible}.character{transform-origin:130px 260px}.portrait{transform-origin:130px 125px}.pose,.face{display:none}.pose-ready,.face-ready{display:inline}.fighter-caption{min-height:30px;position:relative;z-index:7;display:flex;align-items:center;justify-content:center;margin-top:-3px;font-size:12px;font-weight:900}.choice-status:not(:empty),.action-label:not(:empty){display:inline-flex;align-items:center;justify-content:center;min-height:28px;padding:5px 12px;background:#faffed;border-radius:10px;color:#235646;box-shadow:0 3px 0 #2c896738}.fighter[data-locked=true] .choice-status{background:#d6f789;animation:lock-in .2s ease-out}.fighter[data-locked=true] .fighter-name{box-shadow:0 0 0 3px #d6f789}.duel-cue{position:absolute;left:50%;top:43%;transform:translate(-50%,-50%);display:flex;flex-direction:column;align-items:center;z-index:5}.versus{font-size:31px;font-weight:950;font-style:italic;color:#fff;text-shadow:0 3px 0 #318d7b;transform:rotate(-9deg)}.countdown-number{display:none}.clash-label{position:absolute;left:50%;top:21%;transform:translate(-50%,-50%) rotate(-5deg);background:#fff6c7;color:#27483d;border:3px solid #fff;box-shadow:0 5px 0 #20695830;border-radius:15px;padding:8px 20px;font-size:26px;font-weight:950;white-space:nowrap;z-index:10}.clash-label:empty{display:none}#match[data-phase=countdown] .duel-cue{top:49%}#match[data-phase=countdown] .versus{font-size:20px;margin-bottom:8px}#match[data-phase=countdown] .countdown-number{display:grid;place-items:center;background:#fff9de;width:88px;height:94px;border-radius:27px;color:#28533e;font-size:66px;line-height:1;box-shadow:0 7px 0 #2b876135;transform:rotate(-5deg)}.countdown-number[data-count="3"]{animation:count-three .25s ease-out}.countdown-number[data-count="2"]{animation:count-two .25s ease-out}.countdown-number[data-count="1"]{animation:count-one .25s ease-out}#match[data-phase=countdown] .fighter .character{animation:enter-left .4s ease-out}#match[data-phase=selecting] .character{animation:idle 2.6s ease-in-out infinite}#match[data-phase=selecting] .fighter[data-fighter="1"] .character{animation-delay:-1.3s} +/* Fixed order and tactile depth keep action targeting stable throughout a turn. */ +.action-buttons{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:12px;margin-top:20px;padding-bottom:5px}.action-buttons button{display:flex;align-items:center;flex-direction:column;justify-content:center;gap:7px;min-height:128px;padding:12px 4px 10px;border-radius:20px;border-color:#cbdccb;border-bottom-width:6px;background:#fff;--action-color:#6aa72b}.action-buttons button[data-action=blast]{--action-color:#ec8c34}.action-buttons button[data-action=guard]{--action-color:#379dd0}.action-buttons button[data-action=beam]{--action-color:#9564d7}.action-buttons button[data-action=dodge]{--action-color:#da7496}.action-symbol{width:44px;height:44px;color:var(--action-color);display:block}.action-symbol svg{width:100%;height:100%}.action-buttons strong{font-size:15px;line-height:1.3;letter-spacing:-.5px}.action-buttons small{font-size:11px;line-height:1.3;color:#5e7366;font-weight:600;white-space:nowrap}.action-buttons button:hover:not(:disabled){border-color:var(--action-color);background:#f5fce9}.action-buttons button:disabled{background:#f0f3ed;border-color:#dce4d9;color:#748071}.action-buttons button:disabled .action-symbol{opacity:.45}.action-buttons button[data-unavailable=true] .action-symbol{filter:grayscale(.8)}.action-buttons button.chosen{border-color:#79b835;background:#e6fbbd;color:#305615;box-shadow:0 0 0 2px #a9d85f}.action-buttons button.chosen .action-symbol{opacity:1}.chosen-mark{display:none;position:absolute;right:-5px;top:-8px;width:28px;height:28px;padding:5px;border-radius:50%;color:#fff;background:#609f26;border:2px solid #f7faf5}.chosen-mark svg{width:100%;height:100%}.chosen .chosen-mark{display:block;animation:lock-in .2s ease-out}.match-result{padding:18px 26px 20px;background:#e9f5d5;border:2px solid #c9dfa8;border-bottom-width:5px;border-radius:24px;margin:18px auto 0;text-align:center;max-width:580px}.result-heading{display:flex;justify-content:center;align-items:center;gap:14px}.result-medal{display:grid;place-items:center;width:58px;height:58px;border-radius:18px;background:#d4eba2;color:#638f20;transform:rotate(-7deg)}.result-medal svg{width:38px;height:38px}.match-result h2{font-size:33px;letter-spacing:-1px;margin:0;line-height:1.15;color:#487718}.finish-reason{font-size:12px;color:#64735b;margin:5px 0 0}.result-stats{font-size:13px;margin-top:12px;font-weight:800;color:#4f6743}.save-status,.rematch-status{font-size:12px;margin:10px 0 0;color:#637560;line-height:1.5}.save-status:empty,.rematch-status:empty,.result-stats:empty{display:none}.match-result .split{margin-top:16px}.match-result[data-result=loss]{background:#f1eef8;border-color:#dbd4e9}.match-result[data-result=loss] h2{color:#6d608a}.match-result[data-result=loss] .result-medal{background:#e0d9ed;color:#8e7bab;transform:rotate(14deg)}.match-result[data-result=draw]{background:#e8f2f6;border-color:#c7dce4}.match-result[data-result=draw] h2{color:#547c89}.match-result[data-result=draw] .result-medal{background:#d3e7ed;color:#6699a5}.match-result[data-result=loss] .result-medal svg,.match-result[data-result=draw] .result-medal svg{display:none}.match-result[data-result=loss] .result-medal:after{content:'';width:26px;height:20px;border-top:5px solid currentColor;border-radius:50%;transform:translateY(8px)}.match-result[data-result=draw] .result-medal:after{content:'';width:26px;height:14px;border-block:5px solid currentColor}#match[data-phase=finished] .timing{display:none}#match[data-phase=finished] .arena{min-height:300px}#match[data-phase=finished] .avatar{max-width:240px}#match[data-phase=finished] .fighter-caption{display:none} +/* Action poses and expressions. No action is placed on the opponent until reveal. */ +.fighter[data-action] .pose-ready,.fighter[data-action] .face-ready,.fighter[data-reaction] .pose-ready,.fighter[data-reaction] .face-ready{display:none}.fighter[data-action=charge] .pose-charge,.fighter[data-action=blast] .pose-attack,.fighter[data-action=beam] .pose-attack,.fighter[data-action=guard] .pose-guard,.fighter[data-action=dodge] .pose-dodge,.fighter[data-action] .face-focus{display:inline}.fighter[data-hit=true] .face-focus{display:none}.fighter[data-hit=true] .face-loss{display:inline}.fighter[data-reaction] .pose,.fighter[data-reaction] .face{display:none}.fighter[data-reaction=win] .pose-win,.fighter[data-reaction=win] .face-win,.fighter[data-reaction=loss] .pose-loss,.fighter[data-reaction=loss] .face-loss,.fighter[data-reaction=draw] .pose-draw,.fighter[data-reaction=draw] .face-draw{display:inline}.fighter[data-reaction=win] .character{animation:victory .7s ease-out both}.fighter[data-reaction=loss] .character{transform:translateY(8px) rotate(-5deg);transform-origin:130px 275px}.fighter[data-reaction=loss] .portrait{transform:rotate(-7deg)}.fighter[data-reaction=draw] .portrait{transform:rotate(8deg)}.fighter[data-reaction=draw] .character{animation:shrug .6s ease-out both} +/* All reveal motion shares a server-supplied duration and negative elapsed offset. */ +.aura,.projectile,.shield,.impact,.motion-trails,.clash-burst{position:absolute;pointer-events:none;opacity:0}.aura{inset:12% 2% 7%;border-radius:45% 45% 40% 40%;background:#e8ff92;border:7px solid #ffffbb;box-shadow:0 0 0 12px #d4fd8740;z-index:0}.projectile{top:50%;left:78%;width:44%;height:25%;border-radius:50%;background:#fff8c4;border:6px solid #ffc55c;box-shadow:0 0 0 8px #fff4b650;z-index:5;transform-origin:left center}.fighter[data-fighter="1"] .projectile{left:auto;right:78%}.projectile:before{content:'';position:absolute;right:80%;top:20%;width:60%;height:60%;background:#fff4b699;clip-path:polygon(0 0,100% 15%,100% 85%,0 100%,35% 50%)}.fighter[data-fighter="1"] .projectile:before{right:auto;left:80%;transform:scaleX(-1)}.fighter[data-action=beam] .projectile{width:75%;height:32%;border-radius:45%;border-color:#9a9aef;background:#edffff;box-shadow:0 0 0 9px #eafaff70}.fighter[data-action=beam] .projectile:before{background:#e9fdff}.shield{inset:13% -3% 6% 50%;border:7px solid #f3ffff;background:#8fdafa77;border-radius:55%;box-shadow:0 0 0 7px #60b4e579;z-index:4}.fighter[data-fighter="1"] .shield{inset:13% 50% 6% -3%}.impact{width:55%;height:55%;left:22%;top:22%;background:#fff6bd;clip-path:polygon(50% 0,60% 29%,86% 8%,75% 39%,100% 50%,75% 61%,90% 90%,59% 75%,50% 100%,40% 74%,10% 90%,25% 60%,0 50%,25% 40%,10% 10%,40% 28%);z-index:7}.motion-trails{inset:20% 0;z-index:3}.motion-trails i{display:block;height:7px;background:#f4ffff;border-radius:8px;width:95%;margin:26px 0;transform:rotate(-12deg)}.clash-burst{top:45%;left:50%;width:100px;height:120px;background:#fff6b5;clip-path:polygon(50% 0,60% 33%,88% 9%,77% 43%,100% 50%,73% 62%,88% 95%,58% 76%,50% 100%,40% 73%,10% 94%,23% 58%,0 50%,23% 40%,10% 10%,40% 30%);z-index:8}.fighter[data-action=charge] .aura{animation:charge var(--reveal-duration) var(--elapsed) both}.fighter[data-action=charge] .character{animation:power-up var(--reveal-duration) var(--elapsed) both}.fighter[data-action=blast] .projectile,.fighter[data-action=beam] .projectile{animation:projectile var(--reveal-duration) var(--elapsed) both}.fighter[data-action=blast] .character,.fighter[data-action=beam] .character{animation:attack var(--reveal-duration) var(--elapsed) both}.fighter[data-action=guard] .shield{animation:shield var(--reveal-duration) var(--elapsed) both}.fighter[data-action=dodge] .character-direction{animation:teleport var(--reveal-duration) var(--elapsed) both}.fighter[data-action=dodge] .motion-trails{animation:shield var(--reveal-duration) var(--elapsed) both}.fighter[data-hit=true] .character{animation:hit var(--reveal-duration) var(--elapsed) both}.fighter[data-hit=true] .impact{animation:impact var(--reveal-duration) var(--elapsed) both}.arena[data-outcome=clash] .clash-burst{animation:clash var(--reveal-duration) var(--elapsed) both}.arena[data-outcome] .clash-label{animation:outcome var(--reveal-duration) var(--elapsed) both}.arena[data-outcome] .energy-change{animation:energy-gain var(--reveal-duration) var(--elapsed) both} +dialog{max-width:560px;width:calc(100% - 32px);max-height:85svh;border:2px solid #d2e1d4;border-bottom-width:6px;border-radius:25px;padding:24px;color:var(--ink);box-shadow:0 25px 70px #214b4330}dialog::backdrop{background:#183e3c80;backdrop-filter:blur(4px)}.dialog-top{display:flex;align-items:center;justify-content:space-between;gap:12px}.dialog-top h2{font-size:21px;margin:0}.rule-row{display:grid;grid-template-columns:95px 1fr;gap:12px;font-size:13px;padding:12px 0;border-bottom:1px solid var(--line)}dialog p{font-size:13px;line-height:1.8;color:#536b62} +@keyframes idle{0%,100%{transform:translateY(0) rotate(-1deg)}50%{transform:translateY(-4px) rotate(1deg)}}@keyframes waiting{to{opacity:.2;transform:translateY(-4px)}}@keyframes lock-in{from{transform:scale(.7)}to{transform:scale(1)}}@keyframes count-three{from{transform:scale(1.2) rotate(-8deg)}to{transform:scale(1) rotate(-5deg)}}@keyframes count-two{from{transform:scale(1.2) rotate(8deg)}to{transform:scale(1) rotate(-5deg)}}@keyframes count-one{from{transform:scale(1.2) rotate(-8deg)}to{transform:scale(1) rotate(-5deg)}}@keyframes enter-left{from{transform:translateX(-60px)}to{transform:translateX(0)}}@keyframes charge{0%,100%{opacity:0;transform:scale(.8)}25%,75%{opacity:.85;transform:scale(1.06)}45%{transform:scale(.98)}}@keyframes power-up{0%,100%{transform:translateY(0)}25%,45%,65%{transform:translateY(-5px)}35%,55%{transform:translateY(-2px)}}@keyframes attack{0%,15%,100%{transform:translateX(0)}35%,70%{transform:translateX(12px) rotate(3deg)}}@keyframes projectile{0%,15%{opacity:0;transform:translateX(0) scale(.2)}30%{opacity:1;transform:translateX(0) scale(.85)}65%{opacity:1;transform:translateX(calc(var(--direction)*60%)) scale(1.1)}85%,100%{opacity:0;transform:translateX(calc(var(--direction)*100%)) scale(.6)}}@keyframes shield{0%,100%{opacity:0;scale:.85}20%,75%{opacity:.85;scale:1}}@keyframes teleport{0%,100%{opacity:1;translate:0}28%,68%{opacity:.08;translate:calc(var(--direction)*-24px) 0}80%{opacity:.5;translate:0}}@keyframes hit{0%,38%{transform:translateX(0)}48%,65%{transform:translateX(-16px) rotate(-9deg)}55%,75%{transform:translateX(-5px) rotate(-5deg)}100%{transform:translateX(0)}}@keyframes impact{0%,35%,80%,100%{opacity:0;transform:scale(.5)}48%,60%{opacity:.9;transform:scale(1)}}@keyframes clash{0%,30%,90%,100%{opacity:0;transform:translate(-50%,-50%) scale(.3) rotate(-15deg)}45%,70%{opacity:1;transform:translate(-50%,-50%) scale(1.3) rotate(10deg)}}@keyframes outcome{0%,15%{opacity:0;translate:0 10px;scale:.8}35%,100%{opacity:1;translate:0;scale:1}}@keyframes energy-gain{0%,20%{opacity:0;transform:translateY(10px)}40%,85%{opacity:1;transform:translateY(-5px)}100%{opacity:0;transform:translateY(-16px)}}@keyframes victory{0%{transform:translateY(0)}40%{transform:translateY(-20px) rotate(-4deg)}70%{transform:translateY(0) rotate(2deg)}100%{transform:translateY(0)}}@keyframes shrug{0%{transform:translateY(5px)}60%{transform:translateY(-5px)}100%{transform:translateY(0)}} +@media(min-width:1100px){.avatar{width:280px}.arena{padding-inline:55px}.home-stage{min-height:500px}} +@media(max-width:760px){.app{padding:0 22px}header{height:68px}.home{grid-template-columns:1fr;gap:24px;max-width:480px;margin:auto}.home-stage{min-height:0;height:340px;padding-top:16px;border-radius:30px}.home h1{font-size:44px;margin:8px 0 0}#home-fighters{width:100%;max-width:390px;margin:auto;padding:15px 8px 8px}.home-fighter{width:47%;max-width:180px}.home-menu{padding:0}.home-versus{top:42%;font-size:28px}.home-platform{height:110px}.stats{margin-top:24px;padding-top:18px}.home-actions>.primary{min-height:64px;font-size:19px}.home-actions .split button{min-height:54px}.stat-mode>div{gap:30px}.stat-mode{padding:0 26px}.stat-mode:first-child{padding-left:18px}.stat-mode strong{font-size:26px}.arena{min-height:310px;padding:16px 15px 13px}.avatar{max-width:220px}.action-buttons{gap:9px}.action-buttons strong{font-size:13px}.action-buttons small{font-size:10px}.match-top{margin-bottom:12px}.turn-heading{font-size:22px}.fighter-info{gap:6px}.energy{padding-right:7px;min-width:58px}.energy-change{right:-22px;font-size:20px}.clash-label{font-size:21px;padding:7px 14px}.match-result{margin-top:14px;padding:15px 20px}} +@media(max-width:520px){.app{padding:0 16px}header{height:60px;gap:8px}.brand{font-size:17px;gap:7px}.brand-ball{width:30px;height:30px}.header-tools{gap:4px}#connection{font-size:10px;gap:4px}.quiet{padding:9px 10px;font-size:12px}main{padding:12px 0 18px}.home{gap:20px}.home-stage{height:300px;border-radius:25px}.home h1{font-size:40px}#home-fighters{max-width:340px;padding-bottom:5px}.home-fighter{max-width:155px}.home-menu{padding-inline:5px}.home-actions{gap:12px}.stats{margin-top:22px}.stat-mode>div{gap:24px}.stat-mode>span{font-size:12px}.stat-mode strong{font-size:24px}.match-title{gap:9px}.mode-label{font-size:11px;padding:6px 8px}.turn-heading{font-size:21px}.match-top{margin-bottom:9px}.timing{min-height:44px;font-size:12px}.time-label{font-size:16px}.time-track{height:8px;bottom:9px}.arena{min-height:262px;padding:13px 8px 10px;border-radius:23px}.fighter{width:46%}.fighter-info{height:32px;gap:5px}.fighter-name{font-size:12px;padding:5px 9px;border-radius:8px}.energy{border-radius:9px;min-width:48px;padding:1px 6px 1px 2px;gap:1px}.energy>svg{width:21px;height:21px}.energy-count{font-size:17px}.energy-change{font-size:18px;right:-20px;top:3px}.avatar{width:100%;max-width:185px;margin-top:9px}.fighter-caption{min-height:27px;font-size:10px;margin-top:0}.choice-status:not(:empty),.action-label:not(:empty){min-height:25px;padding:5px 8px;border-radius:8px}.versus{font-size:24px}.duel-cue{top:48%}#match[data-phase=countdown] .countdown-number{width:62px;height:70px;font-size:47px;border-radius:20px}#match[data-phase=countdown] .versus{font-size:16px}.clash-label{font-size:19px;padding:6px 12px;top:27%;border-radius:12px}.action-buttons{grid-template-columns:repeat(6,minmax(0,1fr));gap:10px;margin-top:15px}.action-buttons button{grid-column:span 2;min-height:96px;padding:9px 4px 8px;gap:4px;border-radius:15px;border-bottom-width:5px}.action-buttons button:nth-child(4),.action-buttons button:nth-child(5){grid-column:span 3;display:grid;grid-template-columns:42px auto;grid-template-rows:auto auto;align-content:center;column-gap:10px;row-gap:4px;padding:9px 12px;min-height:78px;text-align:left}.action-buttons button:nth-child(4) .action-symbol,.action-buttons button:nth-child(5) .action-symbol{grid-row:span 2;align-self:center}.action-buttons strong{font-size:13px;white-space:nowrap}.action-buttons small{font-size:10px}.action-symbol{width:34px;height:34px}.action-buttons button:nth-child(4) .action-symbol,.action-buttons button:nth-child(5) .action-symbol{width:39px;height:39px}.chosen-mark{width:25px;height:25px;right:-4px;top:-7px}.match-result{padding:14px 16px 16px;border-radius:20px}.match-result h2{font-size:28px}.result-medal{width:48px;height:48px;border-radius:14px}.result-medal svg{width:33px;height:33px}.result-stats{font-size:12px;margin-top:9px}.match-result .split{margin-top:12px}.match-result .split button{font-size:14px;padding:10px 12px}.finish-reason{font-size:11px}#match[data-phase=finished] .arena{min-height:250px}#match[data-phase=finished] .avatar{max-width:170px}footer{padding:4px 0 8px}footer .quiet{font-size:10px;min-height:34px}.room-code{font-size:36px;letter-spacing:6px}.waiting .split button{padding:12px 8px;font-size:13px}.rule-row{grid-template-columns:82px 1fr;gap:8px;font-size:12px}} +@media(max-width:380px){.app{padding:0 12px}.home-stage{height:270px}.home h1{font-size:35px}.home-fighter{max-width:139px}.arena{min-height:236px}.avatar{max-width:157px}.action-buttons button:nth-child(4),.action-buttons button:nth-child(5){grid-template-columns:30px auto;column-gap:6px;padding-inline:9px}.action-buttons button:nth-child(4) .action-symbol,.action-buttons button:nth-child(5) .action-symbol{width:29px;height:29px}.energy-change{right:-16px;font-size:16px}.fighter-info{gap:4px}.fighter-name{padding-inline:7px}.stat-mode{padding-inline:19px}.match-title{gap:7px}} +@media(max-height:720px) and (max-width:520px){header{height:52px}main{padding-top:6px;padding-bottom:8px}.arena{min-height:222px}.avatar{max-width:150px}.action-buttons{gap:8px;margin-top:12px}.action-buttons button{min-height:87px}.action-buttons button:nth-child(4),.action-buttons button:nth-child(5){min-height:69px}.match-top{margin-bottom:7px}.timing{min-height:40px}.home-stage{height:250px}.home-fighter{max-width:130px}.home h1{font-size:34px}.home{gap:16px}.home-actions>.primary{min-height:58px}.stats{margin-top:18px;padding-top:14px}#match[data-phase=finished] .arena{min-height:220px}#match[data-phase=finished] .avatar{max-width:148px}.match-result{margin-top:12px}} +@media(prefers-reduced-motion:reduce){*,*:before,*:after{animation:none!important;transition:none!important;scroll-behavior:auto!important}.fighter[data-action=charge] .aura{opacity:.5}.fighter[data-action=guard] .shield{opacity:.75}.fighter[data-action=blast] .projectile,.fighter[data-action=beam] .projectile{opacity:1}.fighter[data-action=dodge] .character-direction{opacity:.4}.fighter[data-action=dodge] .motion-trails{opacity:.8}.fighter[data-hit=true] .impact{opacity:.6}.arena[data-outcome=clash] .clash-burst{opacity:.8;transform:translate(-50%,-50%)}} +/* Keep every action visible before the deadline on shorter desktop windows. */ +@media(min-width:521px) and (max-height:800px){ + body[data-screen=match] header,body[data-screen=result] header{height:60px} + body[data-screen=match] main,body[data-screen=result] main{padding:4px 0} + body[data-screen=match] footer,body[data-screen=result] footer{display:none} + .match-top{margin-bottom:8px}.timing{min-height:36px;padding-bottom:16px}.time-track{bottom:6px;height:8px} + .arena{min-height:280px;padding:16px 24px 12px} + .avatar{width:clamp(150px,calc((100svh - 480px)*.8),245px)} + .action-buttons{margin-top:14px}.action-buttons button{min-height:104px;gap:5px;padding-block:9px} + .action-symbol{width:36px;height:36px} +} +@media(min-width:521px) and (max-height:660px){ + body[data-screen=match] header,body[data-screen=result] header{height:48px} + .match-top{margin-bottom:6px}.leave{min-height:32px;padding-block:6px}.timing{min-height:30px} + .arena{min-height:230px;padding-block:10px}.avatar{width:130px} + .action-buttons{margin-top:10px}.action-buttons button{min-height:84px;gap:4px;padding-block:7px} + .action-symbol{width:28px;height:28px} +} +/* Keep both action rows in reach on short phones. */ +@media(max-height:720px) and (max-width:520px){ + body[data-screen=match] header,body[data-screen=result] header{height:48px} + body[data-screen=match] main,body[data-screen=result] main{padding:4px 0} + body[data-screen=match] footer,body[data-screen=result] footer{display:none} + .match-top{margin-bottom:5px}.leave{min-height:32px;padding-block:6px} + .timing{min-height:32px;padding-bottom:15px}.time-track{bottom:5px;height:7px} + .arena{padding-block:8px;min-height:214px}.fighter-info{height:28px} + .avatar{max-width:128px;margin-top:4px}.fighter-caption{min-height:23px} + .choice-status:not(:empty),.action-label:not(:empty){min-height:22px;padding-block:4px} + .action-buttons{margin-top:10px}.action-buttons button{min-height:78px;padding-block:6px} + .action-buttons button:nth-child(4),.action-buttons button:nth-child(5){min-height:64px} + .action-symbol{width:29px;height:29px} +} +/* Ink, mineral tones and cloth colours; viewport sizing above stays shared. */ +:root{font-family:"Segoe UI","Malgun Gothic",sans-serif;color:#303638;background:#f3f1eb;--ink:#303638;--muted:#6d7372;--mint:#dedfd5;--lime:#a5ad92;--sky:#c7d0cf;--line:#d2d4ca;--green:#6c7652} +button{border-radius:13px}button:hover:not(:disabled){background:#ebece5;border-color:#a9b0a2}button.primary{background:#a85039;border-color:#773927;color:#fff9ee}button.primary:hover:not(:disabled){background:#b45c44;border-color:#773927}button.primary:disabled{background:#ddd9ce;border-color:#c2c0b3;color:#7c7c70}.quiet{color:#606964}.quiet:hover:not(:disabled){background:#e5e7de}#connection{color:#6e746c}#connection.connected:before{background:#718158} +.home-stage{background:#dddcd2;border-radius:22px;border:1px solid #c8c8bc}.home-stage:before{inset:0;width:auto;height:auto;border:0;border-radius:0;background:repeating-linear-gradient(153deg,transparent 0 65px,#6c79771c 66px 68px,transparent 69px 100px);z-index:-1}.home-stage:after{background:#b9c5c2;right:-80px;width:52%;border-left:2px solid #889692;transform:skew(-16deg)}.home h1{color:#303b3a;transform:none;letter-spacing:-2px}.home-fighter:first-child{transform:translateX(6px)}.home-fighter:last-child{transform:scaleX(-1) translateX(6px)}.home-fighter .character{animation:combat-breathe 4s ease-in-out infinite}.home-platform{background:#b3b5a7;border-top:3px solid #8a9287;border-radius:0;transform:skewY(-6deg);height:90px;bottom:-20px}.home-versus{color:#98543e;text-shadow:2px 2px #eee9d9;font-style:italic;letter-spacing:-3px}.home-actions>.primary{border-radius:16px}.stat-mode>span,.stat-mode strong{color:#4d5951}.stat-mode>div{color:#6b756c}.stats{border-color:#d0d3c7}.stat-mode+.stat-mode{border-color:#d0d3c7}.room-code{background:#e6e6db;border-color:#aeb4a0;border-radius:14px}#waiting-fighter{background:#d8ddd3;border-radius:12px}.waiting-dots i{background:#8a735b} +.mode-label{color:#566057;background:#e0e4d8;border-radius:5px}.time-track{background:#d9dbd0}.time-track span{background:#858d68}#match[data-locked=true] .time-track span{background:#687f84}.arena{background:#e5e2d9;border-radius:18px;border:1px solid #bec4b9}.arena:before{background:#c4cfcd;border-left:3px solid #f1f0e0;transform:skew(-14deg)}.arena:after{inset:35% -10% 0;width:120%;height:75%;border:0;border-radius:0;background:repeating-linear-gradient(172deg,transparent 0 32px,#7c858514 33px 34px,transparent 35px 60px);z-index:-1}.arena-floor{height:25%;bottom:-8%;background:#8d998b25;border-top:2px solid #949d9130}.fighter-name{background:#f5f2e8;color:#3b4541;border-radius:6px}.fighter.self .fighter-name{background:#414f47}.energy{background:#f4f1e5;border-color:#f8f6ef;border-radius:7px}.energy>svg{color:#8a7642}.energy-change{color:#6d5436;text-shadow:0 1px #f5f0df}.choice-status:not(:empty),.action-label:not(:empty){background:#f6f0df;color:#3f4840;border-radius:5px;box-shadow:0 2px 0 #6c756b35}.fighter[data-locked=true] .choice-status{background:#e0e7cd}.fighter[data-locked=true] .fighter-name{box-shadow:0 0 0 2px #a9b293}.versus{color:#65716a;text-shadow:1px 2px #f8f3e8;letter-spacing:-3px}#match[data-phase=countdown] .countdown-number{background:#f5efdf;border:2px solid #3f4b45;color:#3f4b45;border-radius:14px;box-shadow:4px 5px 0 #69756935}.clash-label{background:#f6efda;color:#3d4841;border-color:#48534b;border-width:2px;border-radius:6px;box-shadow:3px 4px 0 #454a3535}.action-buttons button{border-radius:12px;border-color:#b7beb0;background:#fdfcf6;--action-color:#78835a}.action-buttons button[data-action=blast]{--action-color:#ad5a3e}.action-buttons button[data-action=guard]{--action-color:#537c89}.action-buttons button[data-action=beam]{--action-color:#925d51}.action-buttons button[data-action=dodge]{--action-color:#666e85}.action-buttons button:hover:not(:disabled){background:#f1f0e6}.action-buttons button:disabled{background:#e9eae2;border-color:#d0d4c8;color:#757c70}.action-buttons button.chosen{background:#ebe6d4;border-color:#a98452;color:#5b4f36;box-shadow:0 0 0 1px #bea577}.action-buttons small{color:#677162}.chosen-mark{background:#8b744d;border-color:#f3f1eb}.match-result{background:#e7eadf;border-color:#bec7af;border-radius:16px}.match-result h2{color:#556449}.result-medal{background:#d3dcc3;color:#697b50;border-radius:10px;transform:rotate(-5deg)}.result-stats{color:#56614e}.match-result[data-result=loss]{background:#e7e3df;border-color:#cbbfb7}.match-result[data-result=loss] h2{color:#8b584b}.match-result[data-result=loss] .result-medal{background:#d9ccc3;color:#95634f}.match-result[data-result=draw]{background:#e2e8e5;border-color:#bacac2}.match-result[data-result=draw] h2{color:#556b66}.match-result[data-result=draw] .result-medal{background:#cbd9d2;color:#627a6d} +/* Adult proportions fill exactly the old art box, with individually drawn limbs. */ +.character{transform-origin:130px 230px}.portrait{transform-origin:130px 61px}.legs-airborne{display:none}#match[data-phase=selecting] .character{animation:combat-breathe 4s ease-in-out infinite}.fighter[data-reaction=win] .character{animation:resolve-stance .45s ease-out both}.fighter[data-reaction=loss] .character{transform:translateY(5px) rotate(-9deg)}.fighter[data-reaction=loss] .portrait{transform:rotate(-9deg)}.fighter[data-reaction=draw] .portrait{transform:rotate(2deg)}.fighter[data-reaction=draw] .character{animation:resolve-stance .45s ease-out both}.fighter[data-hit=true] .pose,.fighter[data-hit=true] .face,.fighter[data-hit=true] .legs-stance{display:none}.fighter[data-hit=true] .pose-hit,.fighter[data-hit=true] .face-shock,.fighter[data-hit=true] .legs-airborne{display:inline} +.aura{background:radial-gradient(ellipse at 50% 65%,#fff6ad 0%,#ffdc45 48%,#f5a623 100%);border-color:#ffe46b;border-width:3px;border-radius:0;box-shadow:none;clip-path:polygon(8% 100%,0 51%,15% 60%,12% 20%,32% 43%,40% 0,58% 27%,79% 8%,77% 48%,98% 28%,93% 85%,100% 100%)}.projectile{top:34%;height:16%;border-width:3px;border-color:#b46b46;background:#fff2c8;box-shadow:0 0 0 5px #e7c87e60;border-radius:48% 55% 55% 48%}.fighter[data-action=beam] .projectile{height:23%;border-color:#688a97;background:#eef3e8;box-shadow:0 0 0 5px #a5bfbd70;border-radius:35%}.shield{border-width:3px;border-color:#547c89;background:#b0cfd149;box-shadow:none;border-radius:40%;transform:skewY(-10deg)}.fighter[data-fighter="1"] .shield{transform:skewY(10deg)}.motion-trails i{height:3px;background:#465b62}.clash-burst{background:#ede1b8;border-color:#3d4445}.fighter[data-action=blast] .projectile,.fighter[data-action=beam] .projectile{animation-name:combat-projectile} +@keyframes combat-breathe{0%,100%{transform:translateY(0)}50%{transform:translateY(-1.5px)}} +@keyframes resolve-stance{from{transform:translateY(3px)}to{transform:translateY(0)}} +@keyframes combat-projectile{0%,12%{opacity:0;transform:translateX(0) scale(.25)}24%{opacity:1;transform:translateX(0) scale(.7)}58%,72%{opacity:1;transform:translateX(var(--shot-distance)) scale(1)}90%,100%{opacity:0;transform:translateX(var(--shot-distance)) scale(.65)}} +/* Full viewport, native top-layer modal: no app chrome or background tab targets. */ +body[data-cutscene=active]{overflow:hidden} +body[data-cutscene=active] .app{visibility:hidden} +dialog.finish-cutscene{position:fixed;inset:0;width:100vw;height:100dvh;max-width:none;max-height:none;margin:0;padding:0;border:0;border-radius:0;background:#080a0a;color:#eee7d6;overflow:hidden;box-shadow:none;outline:none;--combatant-size:min(42vw,35dvh);--hit-x:calc(50% + var(--combatant-size)*.48)} +dialog.finish-cutscene::backdrop{background:#080a0a;backdrop-filter:none} +.finish-cutscene[data-hit-index="0"]{--hit-x:calc(50% - var(--combatant-size)*.48)} +.cutscene-page{position:absolute;inset:0;overflow:hidden;background:#080a0a;isolation:isolate} +.cutscene-panel{position:absolute;left:0;width:100%;overflow:hidden;isolation:isolate;background:#e6dfc9} +.cutscene-attack{top:0;height:36%;clip-path:polygon(0 0,100% 0,100% 100%,0 76%);background:#b8c3bc} +.cutscene-impact{top:28%;height:43%;clip-path:polygon(0 0,100% 22%,100% 100%,0 82%);background:#f3ecd4} +.cutscene-fallen{top:64.4%;height:35.6%;clip-path:polygon(0 0,100% 22%,100% 100%,0 100%);background:#d3c8ac} +.cutscene-panel:after{content:'';position:absolute;inset:0;pointer-events:none;background:radial-gradient(#1e2d272e .7px,transparent .9px) 0 0/4px 4px;z-index:5;mix-blend-mode:multiply} +.cutscene-ink-strokes{position:absolute;inset:-30%;background:repeating-linear-gradient(167deg,transparent 0 19px,#2c363c70 20px 22px,transparent 24px 48px);transform:skewY(-5deg)} +.cutscene-attack:before{content:'';position:absolute;inset:0;background:linear-gradient(115deg,#263433b3,transparent 46%,#eee4cb88)} +.finish-cutscene .cutscene-closeup{position:absolute;inset:-5% 0 -55%;width:100%;margin:0} +.cutscene-closeup .fighter-art{filter:drop-shadow(8px 6px 0 #30383144)} +.finish-cutscene .character{animation:none;transform:none} +.finish-cutscene .ground-shadow{display:none} +.finish-cutscene .cutscene-closeup .pose{display:none} +.cutscene-speedlines{position:absolute;left:calc(var(--hit-x) - 50%);top:0;width:100%;height:100%;overflow:visible;opacity:.7} +.finish-cutscene .cutscene-combatant{position:absolute;left:calc(50% - var(--combatant-size));top:53%;width:var(--combatant-size);height:auto;aspect-ratio:260/300;translate:0 -50%;z-index:2} +.finish-cutscene .cutscene-combatant[data-fighter="1"]{left:50%} +.cutscene-combatant .character-direction{height:100%} +.finish-cutscene .cutscene-combatant[data-hit=false] .character{transform:translateX(10px) rotate(3deg)} +.finish-cutscene .cutscene-combatant[data-hit=true] .character{transform:translate(-23px,-10px) rotate(-35deg);transform-origin:130px 150px} +.cutscene-shot{position:absolute;left:calc(50% - var(--combatant-size)*.12);top:43%;width:calc(var(--combatant-size)*.7);height:12%;z-index:3;background:#fffae7;border:3px solid #8c7250;clip-path:polygon(0 27%,80% 0,100% 48%,81% 100%,0 73%);transform:rotate(-4deg)} +.finish-cutscene[data-attack=beam] .cutscene-shot{height:19%;top:40%;border-color:#4d7680;background:#e3f0e7;clip-path:polygon(0 0,84% 8%,100% 50%,84% 92%,0 100%)} +.finish-cutscene[data-hit-index="0"] .cutscene-shot{left:calc(50% - var(--combatant-size)*.58);transform:scaleX(-1) rotate(-4deg)} +.cutscene-contact{position:absolute;left:var(--hit-x);top:47%;width:calc(var(--combatant-size)*.54);height:auto;aspect-ratio:1;transform:translate(-50%,-50%);z-index:4} +.cutscene-sfx{position:absolute;left:50%;top:20%;translate:-50% 0;font-family:"Malgun Gothic","Arial Black",sans-serif;font-size:clamp(54px,12dvh,130px);font-weight:1000;line-height:1;letter-spacing:-.12em;color:#a23f2b;-webkit-text-stroke:5px #202529;paint-order:stroke fill;filter:drop-shadow(3px 4px 0 #f5eedc);transform:rotate(-13deg) skew(-10deg);z-index:6} +.fallen-fighter-art{position:absolute;inset:4% 0 0;width:100%;height:96%;display:block} +.rock-fallen-head .face{display:none}.rock-fallen-head .face-loss{display:inline} +.rock-fallen-art{overflow:visible} +/* Stage changes reveal additional panels; previously revealed panels stay on the page. */ +.finish-cutscene[data-stage=attack] .cutscene-impact,.finish-cutscene[data-stage=attack] .cutscene-fallen,.finish-cutscene[data-stage=impact] .cutscene-fallen{visibility:hidden} +.finish-cutscene[data-stage=attack] .cutscene-attack{animation:cutscene-enter .28s ease-out both} +.finish-cutscene[data-stage=impact] .cutscene-impact{animation:cutscene-enter .22s ease-out both} +.finish-cutscene[data-stage=fallen] .cutscene-fallen{animation:cutscene-enter .4s ease-out both} +.finish-cutscene[data-stage=impact] .cutscene-combatant[data-hit=true] .character{animation:cutscene-hit .35s ease-out both} +.finish-cutscene .cutscene-result{position:absolute;right:max(16px,env(safe-area-inset-right));bottom:max(14px,env(safe-area-inset-bottom));z-index:10;min-height:44px;padding:9px 17px;border:1px solid #d5cfbb;border-radius:3px;background:#141a18;color:#fff7e4;font-size:13px;line-height:1.2;box-shadow:2px 3px 0 #05070780} +.finish-cutscene .cutscene-result:hover{background:#303c35;border-color:#fff5db;transform:none} +.finish-cutscene .cutscene-result:focus-visible{outline:3px solid #fff6cd;outline-offset:4px} +@keyframes cutscene-enter{from{opacity:0;translate:0 14px}to{opacity:1;translate:0 0}} +@keyframes cutscene-hit{from{transform:translate(-6px,-3px) rotate(-12deg)}to{transform:translate(-23px,-10px) rotate(-35deg)}} +@media(max-width:520px){ + .cutscene-sfx{-webkit-text-stroke-width:3px;filter:drop-shadow(2px 3px 0 #f5eedc)} + .fallen-fighter-art{left:-15%;width:130%} + .home-stage{border-radius:18px}.action-buttons button{border-radius:11px} +} +@media(prefers-reduced-motion:reduce){ + .finish-cutscene .cutscene-panel{animation:none;translate:none;opacity:1} + .fighter[data-action=blast] .projectile,.fighter[data-action=beam] .projectile{transform:translateX(var(--shot-distance))} +} +/* Beam panels share the character's 260 × 300 hand coordinates. */ +.finish-cutscene[data-attack=beam]{--beam-caster-size:min(48vw,30dvh)} +.finish-cutscene[data-attack=beam] .beam-impact{background:#d5e6e5} +.finish-cutscene[data-attack=beam] .beam-rush-lines{position:absolute;inset:-50%;background:repeating-linear-gradient(172deg,transparent 0 16px,#344e5970 17px 19px,transparent 20px 37px);transform:skewY(-7deg)} +.finish-cutscene[data-attack=beam] .beam-launch{position:absolute;left:4%;top:54%;width:var(--beam-caster-size);aspect-ratio:260/300;translate:0 -50%;z-index:3} +.finish-cutscene[data-attack=beam] .beam-launch[data-caster="1"]{left:auto;right:4%;transform:scaleX(-1)} +.finish-cutscene[data-attack=beam] .beam-caster{width:100%;height:100%} +.finish-cutscene[data-attack=beam] .beam-caster .character-direction{transform:none} +.finish-cutscene[data-attack=beam] .beam-ray{position:absolute;inset:0;width:100%;height:100%;overflow:visible;z-index:3;transform-origin:80% 40%;filter:drop-shadow(0 0 4px #c7f9ff)} +.finish-cutscene[data-attack=beam] .beam-impact-victim{position:absolute;right:2%;top:54%;width:var(--beam-caster-size);aspect-ratio:260/300;translate:0 -50%;z-index:2;visibility:hidden} +.finish-cutscene[data-attack=beam] .beam-impact-victim[data-fighter="0"]{right:auto;left:2%} +.finish-cutscene[data-attack=beam] .beam-impact-victim .character{transform:translate(-10px,-8px) rotate(-25deg);transform-origin:130px 150px} +.finish-cutscene[data-attack=beam][data-stage=impact] .beam-ray{animation:beam-cutscene-fire .3s ease-out both} +.finish-cutscene[data-attack=beam] .beam-aftermath{background:#080a0a;animation:none;translate:none;opacity:1} +.finish-cutscene[data-attack=beam] .beam-aftermath:after{opacity:.16} +.finish-cutscene[data-attack=beam] .beam-reaction{position:absolute;inset:0 0 0 42%;overflow:hidden;clip-path:polygon(24% 0,100% 0,100% 100%,0 100%);background:#fcffff;visibility:hidden} +.finish-cutscene[data-attack=beam] .beam-shock-lines{position:absolute;inset:-30%;background:repeating-linear-gradient(153deg,transparent 0 39px,#a9e9f044 40px 41px,transparent 42px 71px),radial-gradient(ellipse at 40% 45%,#fcffff 20%,#d4faff 100%)} +.finish-cutscene[data-attack=beam] .beam-reaction-portrait{position:absolute;inset:-1% -10% -40% -8%;width:118%;height:141%} +.finish-cutscene[data-attack=beam] .beam-reaction-art{display:block;width:100%;height:100%;overflow:visible} +.finish-cutscene[data-attack=beam] .beam-reaction-remnant{filter:brightness(1.18) saturate(.75)} +.finish-cutscene[data-attack=beam] .beam-reaction-erosion{transform:translateX(0)} +.finish-cutscene[data-attack=beam] .beam-reaction-light{opacity:.85} +.finish-cutscene[data-attack=beam] .beam-reaction-portrait .pose{display:none} +.finish-cutscene[data-attack=beam] .beam-dissolve{position:absolute;inset:0 auto 0 0;width:62%;overflow:hidden;clip-path:polygon(0 0,89% 0,66% 100%,0 100%);background:#fcffff;visibility:hidden} +.finish-cutscene[data-attack=beam] .beam-dissolve-lines{position:absolute;inset:0;background:linear-gradient(145deg,#d4faff55,#fcffff 48%,#e4fcfc)} +.finish-cutscene[data-attack=beam] .beam-dissolve-art{position:absolute;left:0;top:6%;width:90%;height:94%;overflow:visible} +.finish-cutscene[data-attack=beam] .beam-remnant{opacity:.38;filter:brightness(1.25) saturate(.55)} +.finish-cutscene[data-attack=beam] .beam-remnant .character{transform:rotate(-14deg);transform-origin:130px 150px} +.finish-cutscene[data-attack=beam] .beam-vanish-wake{opacity:.42} +.finish-cutscene[data-attack=beam] .beam-fragments{opacity:.6;transform:translateX(0)} +.finish-cutscene[data-attack=beam]:is([data-stage=reaction],[data-stage=fallen],[data-stage=complete]) .beam-reaction{visibility:visible} +.finish-cutscene[data-attack=beam]:is([data-stage=fallen],[data-stage=complete]) .beam-dissolve{visibility:visible} +.finish-cutscene[data-attack=beam][data-stage=reaction] .beam-reaction-erosion{animation:beam-cutscene-erode .55s ease-out both} +.finish-cutscene[data-attack=beam][data-stage=reaction] .beam-reaction-light{animation:beam-cutscene-irradiate .55s ease-out both} +.finish-cutscene[data-attack=beam][data-stage=fallen] .beam-remnant{animation:beam-cutscene-fade .55s ease-out both} +.finish-cutscene[data-attack=beam][data-stage=fallen] .beam-fragments{animation:beam-cutscene-scatter .55s ease-out both} +@keyframes beam-cutscene-fire{from{opacity:.35;transform:scaleX(.15)}to{opacity:1;transform:scaleX(1)}} +@keyframes beam-cutscene-erode{from{transform:translateX(22px)}to{transform:translateX(0)}} +@keyframes beam-cutscene-irradiate{from{opacity:.2}to{opacity:.85}} +@keyframes beam-cutscene-fade{from{opacity:.68}to{opacity:.38}} +@keyframes beam-cutscene-scatter{from{opacity:0;transform:translateX(-12px)}to{opacity:.6;transform:translateX(0)}} +@media(prefers-reduced-motion:reduce){ + .finish-cutscene[data-attack=beam] .beam-ray{animation:none;transform:none;opacity:1} + .finish-cutscene[data-attack=beam] .beam-reaction-erosion,.finish-cutscene[data-attack=beam] .beam-reaction-light,.finish-cutscene[data-attack=beam] .beam-remnant,.finish-cutscene[data-attack=beam] .beam-fragments{animation:none} +} +/* Result-only ash occupies the existing avatar box without hiding player information. */ +.ash-pile-art{display:none} +.arena .fighter[data-ash=true] .avatar > :is(.character-direction,.aura,.motion-trails,.projectile,.shield,.impact){display:none} +.arena .fighter[data-ash=true] .avatar > .ash-pile-art{display:block;position:absolute;inset:0;width:100%;height:100%;pointer-events:none} +.arena .fighter[data-ash=true] .ash-drift{opacity:.55;animation:ash-pile-drift 3.2s ease-out infinite} +.arena .fighter[data-ash=true] .ash-drift-late{animation-delay:-1.6s} +@keyframes ash-pile-drift{0%{opacity:0;transform:translate(0,4px)}20%{opacity:.55}100%{opacity:0;transform:translate(7px,-9px)}} +@media(prefers-reduced-motion:reduce){ + .arena .fighter[data-ash=true] .ash-drift{animation:none;opacity:.55;transform:none} +} diff --git a/src/client/home.css b/src/client/home.css new file mode 100644 index 0000000..7c27854 --- /dev/null +++ b/src/client/home.css @@ -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; } +} diff --git a/src/client/index.html b/src/client/index.html new file mode 100644 index 0000000..e330256 --- /dev/null +++ b/src/client/index.html @@ -0,0 +1,26 @@ + + +쎄쎄쎄:기공대전 + +
+
+
+

쎄쎄쎄:기공대전

+
+
+ +
+
+
+ + +

+ +
+
연결 중
+
+

규칙

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

선택 시간 4초. 선택은 바꿀 수 없고 마감 때 함께 공개됩니다.

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

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

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

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

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

+

개인정보·분석 안내

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

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

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

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

+

문의

+ + diff --git a/src/client/lobby-features.css b/src/client/lobby-features.css new file mode 100644 index 0000000..7837807 --- /dev/null +++ b/src/client/lobby-features.css @@ -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; } +} diff --git a/src/client/lobby-features.ts b/src/client/lobby-features.ts new file mode 100644 index 0000000..e5bb86c --- /dev/null +++ b/src/client/lobby-features.ts @@ -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 = '랭킹 대전'; + actions.prepend(rankedStart); + document.getElementById('bot-start')!.classList.remove('primary'); + const tools = document.createElement('div'); tools.className = 'lobby-tools'; + tools.innerHTML = ''; + actions.after(tools); + const queue = document.createElement('section'); queue.id = 'ranked-queue'; queue.className = 'waiting ranked-queue'; queue.hidden = true; + queue.innerHTML = '

대전 상대 찾는 중

'; + document.getElementById('waiting')!.after(queue); + const appearanceDialog = document.createElement('dialog'); appearanceDialog.id = 'appearance-dialog'; + appearanceDialog.innerHTML = '

캐릭터·장비

피부

머리

도복 색상

장비

안경

'; + const leaderboardDialog = document.createElement('dialog'); leaderboardDialog.id = 'leaderboard-dialog'; + leaderboardDialog.innerHTML = '

랭킹 리더보드

최대 연승순 · 동률이면 누적 승순

'; + document.body.append(appearanceDialog, leaderboardDialog); + const select = (root: ParentNode, selector: string) => root.querySelector(selector)!; + const profileNotice = select(appearanceDialog, '.profile-notice'); + const equip = select(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 | undefined, generation = 0; + let profileRevision = 0; + const lockIcon = ''; + 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 => ``).join(''); + select(appearanceDialog, '.hair-options').innerHTML = HAIR_STYLES.map(hair => ``).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 ``; + }).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 ``; + }; + 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 = `` + + 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('[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('[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('[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('[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('[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('/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(appearanceDialog, '.dialog-top button').onclick = () => appearanceDialog.close(); + select(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('/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('/api/ranked/leaderboard'); + content.replaceChildren(); + if (!board.entries.length) { content.textContent = '아직 랭킹 기록이 없습니다.'; return; } + const table = document.createElement('table'); + table.innerHTML = '순위이름최대 연승누적 승'; + 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('/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('/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('/api/ranked/leave', 'POST', { matchId: latest.match.matchId }) + : await api.request('/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('/api/profile')); return false; } + const status = await api.request('/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('/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(); }, + }; +} diff --git a/src/client/main.ts b/src/client/main.ts new file mode 100644 index 0000000..53f033e --- /dev/null +++ b/src/client/main.ts @@ -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 = (id: string) => document.getElementById(id)! as T; +document.title = GAME_NAME; +const homeFighters = el('home-fighters'); +homeFighters.innerHTML = '
' + fighterArt(0) + '
' + fighterArt(0, false, HOME_TELEPORT_APPEARANCE) + '
' + fighterArt(1) + '
'; +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(); +let storageAvailable = true, ready = false, pending = false, booting = false; +let lobby: ReturnType | 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; +let controller: MatchController | undefined, view: ReturnType | undefined; +let currentMatch: LocalMatchState | undefined, currentRoom: PeerRoom | undefined, currentScreen = ''; +let waitingSocket: Socket | undefined, roomPoll: ReturnType | 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(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, 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 '
' + (mode === 'bot' ? '봇 대전' : '친구 대전') + '
현재 ' + s.current + '최고 ' + s.best + '
'; + }).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 | 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('/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('/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('/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('/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('/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('/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('room-code').focus(); }; +el('join-close').onclick = () => setScreen('home'); +el('join-form').onsubmit = e => { e.preventDefault(); void perform(async () => acceptRoom(await api.request('/api/rooms/join', 'POST', { code: el('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 => '
' + ACTIONS[id].name + '기 ' + ACTIONS[id].cost + ' 소모 · ' + ACTIONS[id].effect + '
').join(''); +for (const name of ['rules', 'privacy', 'contact']) el(name + '-open').onclick = () => el(name + '-dialog').showModal(); +document.querySelectorAll('[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('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(); diff --git a/src/client/match-controller.ts b/src/client/match-controller.ts new file mode 100644 index 0000000..d2d12f8 --- /dev/null +++ b/src/client/match-controller.ts @@ -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 }; storage: SessionStorage; + clock?: GameClock; random?: () => number; onRematch?: () => Promise; + 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 | 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; diff --git a/src/client/match-view.ts b/src/client/match-view.ts new file mode 100644 index 0000000..b102f5e --- /dev/null +++ b/src/client/match-view.ts @@ -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) => `
+
${fighterArt(i, false, appearance)}
${ashPileArt()}
+
+
`; + +export function createMatchView(container: HTMLElement, onAction: (action: ActionId) => Promise, onRematch: () => void, onLeave: () => void, onResult: () => void = () => {}, onRules: () => void = () => {}, appearances?: [FighterAppearance, FighterAppearance]) { + container.innerHTML = `

메뉴
+
+
${fighter(0, appearances?.[0])}
${fighter(1, appearances?.[1])}
+
${actionIcon('charge')}
${ACTION_IDS.map(id => ``).join('')}
+ `; + const arena = container.querySelector('.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 = (selector: string) => container.querySelector(selector)!; + el('.result-banner h2').tabIndex = -1; + const buttons = Array.from(container.querySelectorAll('[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('.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('[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('.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('[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; }, + }; +} diff --git a/src/client/match.css b/src/client/match.css new file mode 100644 index 0000000..8024493 --- /dev/null +++ b/src/client/match.css @@ -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%; } +} diff --git a/src/client/peer.ts b/src/client/peer.ts new file mode 100644 index 0000000..0a87d02 --- /dev/null +++ b/src/client/peer.ts @@ -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; +} +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 | undefined, yes: (() => void) | undefined, no: ((e: Error) => void) | undefined; + let timer: ReturnType | 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 { + 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((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; diff --git a/src/client/public/assets/BlackHanSans-OFL.txt b/src/client/public/assets/BlackHanSans-OFL.txt new file mode 100644 index 0000000..3b9b3c4 --- /dev/null +++ b/src/client/public/assets/BlackHanSans-OFL.txt @@ -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. diff --git a/src/client/result-reporter.ts b/src/client/result-reporter.ts new file mode 100644 index 0000000..4bd2244 --- /dev/null +++ b/src/client/result-reporter.ts @@ -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; +interface ReporterApi { gameSessionId: string; record(data: ResultSubmission): Promise } +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 | 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 { + 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((yes, no) => { resolve = yes; reject = no; }); + const current = promise; void attempt(); return current; + } + return { + submit, + async resumePending(): Promise { + 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; diff --git a/src/client/styles.css b/src/client/styles.css new file mode 100644 index 0000000..3657130 --- /dev/null +++ b/src/client/styles.css @@ -0,0 +1,4 @@ +:root{font-family:Inter,"Segoe UI","Malgun Gothic",sans-serif;color:#172033;background:#f6f7f9;font-synthesis:none;--orange:#e85916;--blue:#326dde;--line:#dfe4ec} +*{box-sizing:border-box}body{margin:0}button,input,select{font:inherit}button{cursor:pointer;border:1px solid var(--line);background:#fff;color:inherit;border-radius:14px;min-height:48px;padding:13px 22px;font-weight:700;transition:background .15s,transform .15s,border-color .15s}button:hover:not(:disabled){background:#edf0f5;transform:translateY(-1px)}button:disabled{cursor:default;color:#687285;background:#edf0f3;transform:none}button:focus-visible,a:focus-visible,input:focus-visible,select:focus-visible{outline:3px solid #326dde;outline-offset:3px}button.primary{background:var(--orange);color:#fff;border-color:var(--orange);box-shadow:0 4px 0 #bc4210}button.primary:hover:not(:disabled){background:#d84e10}button.primary:disabled{background:#a0a9b6;border-color:#a0a9b6;box-shadow:none;color:#fff}.quiet{background:transparent;border-color:transparent;box-shadow:none;font-size:14px;color:#596477;padding:10px 12px;min-height:42px}[hidden]{display:none!important}.app{max-width:1060px;margin:auto;padding:0 36px;min-height:100svh;display:flex;flex-direction:column}header{height:100px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--line)}.brand{display:flex;align-items:center;gap:12px;color:inherit;text-decoration:none;font-weight:850;font-size:21px;letter-spacing:-.6px}.brand-ball{display:grid;place-items:center;width:34px;height:34px;border-radius:50%;background:#ffd7a0;border:2px solid #eea643;color:#b7440e;font-size:18px}.header-tools{display:flex;align-items:center;gap:14px}#connection{font-size:12px;color:#667287;display:flex;align-items:center;gap:6px}#connection:before{content:'';width:6px;height:6px;border-radius:50%;background:#97a0ae}#connection.connected:before{background:#15803d}main{flex:1;display:flex;flex-direction:column;justify-content:center;padding:48px 0 32px}.home{width:min(100%,440px);margin:auto;text-align:center}.home h1{font-size:46px;letter-spacing:-2px;margin:22px 0 34px;font-weight:900}.home-emblem{display:flex;align-items:center;justify-content:center;gap:24px}.home-emblem span{display:grid;place-items:center;width:80px;height:80px;border-radius:26px;background:#fff1dd;color:var(--orange);font-size:46px;box-shadow:0 4px 0 #ecdac5}.home-emblem span:last-child{background:#e9f0ff;color:var(--blue);box-shadow:0 4px 0 #d1def4}.home-emblem i{height:1px;width:30px;background:var(--line)}.stats{display:flex;border:1px solid var(--line);border-radius:18px;background:#fff;overflow:hidden;text-align:left;margin-bottom:30px}.stat-mode{flex:1;padding:20px 24px}.stat-mode+.stat-mode{border-left:1px solid var(--line)}.stat-mode>span{font-size:13px;color:#647086}.stat-mode>div{display:flex;gap:20px;margin-top:8px;font-size:12px;color:#647086}.stat-mode strong{display:block;font-size:27px;color:#172033;line-height:1.5;font-variant-numeric:tabular-nums}.home-actions{display:grid;gap:16px}.home-actions>.primary{font-size:19px;display:flex;justify-content:space-between;padding:18px 24px;min-height:64px}.split{display:flex;gap:12px}.split>*{flex:1}.join-form{padding-top:26px;text-align:left}.join-form>label{display:block;font-size:13px;font-weight:700;margin-bottom:10px}.join-form>div{display:flex;gap:10px}.join-form input{min-width:0;flex:1}.join-form>.quiet{display:block;margin:8px auto 0}input,select{border:1px solid #c8d0dd;border-radius:12px;background:#fff;padding:12px;color:inherit;min-height:46px}#room-code{font-size:23px;letter-spacing:6px;font-family:monospace;text-transform:uppercase}.waiting{max-width:460px;margin:auto;text-align:center}.waiting h1{font-size:24px}.room-code{font-family:monospace;font-size:48px;letter-spacing:8px;border:1px solid var(--line);background:white;border-radius:20px;padding:28px 22px;margin:28px 0}.subtle{font-size:13px;color:#647086}.notice{min-height:22px;max-width:680px;text-align:center;margin:24px auto 0;color:#9b3b13;font-size:14px;line-height:1.6}#reconnect{margin:16px auto;width:max-content}footer{display:flex;justify-content:center;padding:14px 0 20px}footer button{font-size:12px}.match-top{display:flex;justify-content:space-between;align-items:center}.mode-label{font-size:12px;color:#647086}.turn-heading{font-size:25px;margin:5px 0 0;letter-spacing:-.7px}.timing{display:flex;align-items:center;justify-content:center;gap:14px;margin:20px 0 10px;height:28px}.beats{display:flex;gap:6px}.beats i{width:32px;height:6px;border-radius:10px;background:#dce2eb;transition:background .2s}.beats i.lit{background:var(--orange)}.time-label{font-size:13px;min-width:110px;color:#526078;font-variant-numeric:tabular-nums}.arena{position:relative;display:flex;justify-content:space-around;align-items:center;background:#fff;border:1px solid var(--line);border-radius:28px;min-height:310px;padding:22px 8px;overflow:hidden}.arena:after{content:'';position:absolute;bottom:60px;left:12%;right:12%;height:1px;background:#edf0f5}.fighter{width:40%;text-align:center;position:relative;z-index:1;--fighter-color:var(--blue)}.fighter.self{--fighter-color:var(--orange)}.fighter-name{font-size:17px;font-weight:800}.energy{display:flex;flex-direction:column;align-items:center;gap:4px;margin-top:7px;min-height:36px}.energy-count{font-size:13px;font-weight:700;color:var(--fighter-color)}.energy-dots{color:var(--fighter-color);letter-spacing:4px;font-size:9px;height:10px}.avatar{width:155px;height:170px;margin:8px auto 0;position:relative}.avatar svg{height:100%;width:100%;position:relative;z-index:2}.body{fill:var(--fighter-color)}.head{fill:#f3d5b5}.hair{fill:#172033}.belt{fill:#203658}.eyes,.mouth{stroke:#172033;stroke-linecap:round;stroke-width:5;fill:none}.arms{stroke:var(--fighter-color);stroke-linecap:round;stroke-width:22;fill:none}.aura{position:absolute;inset:0;background:var(--fighter-color);border-radius:50%;opacity:0}.action-label{height:24px;font-size:14px;font-weight:800;color:var(--fighter-color);margin-top:8px}.clash-label{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);z-index:3;font-size:17px;font-weight:800;background:#fff;padding:8px 12px;border-radius:20px}.clash-label:empty{display:none}.projectile{position:absolute;top:65%;width:36px;height:36px;background:var(--fighter-color);border-radius:50%;opacity:0;z-index:3}.shield{position:absolute;inset:20px -8px 0;border:4px solid var(--fighter-color);border-radius:50%;opacity:0;z-index:3}.fighter[data-action=charge] .aura{animation:charge 1.2s both;animation-delay:var(--elapsed,0ms)}.fighter[data-action=blast] .projectile,.fighter[data-action=beam] .projectile{animation:blast 1.2s both;animation-delay:var(--elapsed,0ms)}.fighter[data-action=beam] .projectile{width:100px;border-radius:40px;box-shadow:0 0 20px var(--fighter-color);height:46px}.fighter[data-fighter="1"] .projectile{right:0;--direction:-1}.fighter[data-fighter="0"] .projectile{left:0;--direction:1}.fighter[data-action=guard] .shield{animation:guard 1.2s both;animation-delay:var(--elapsed,0ms)}.fighter[data-action=dodge] .avatar{animation:dodge 1.2s both;animation-delay:var(--elapsed,0ms)}.fighter[data-hit=true] .avatar svg{animation:hit 1.2s both;animation-delay:var(--elapsed,0ms)}.action-buttons{display:grid;grid-template-columns:repeat(5,1fr);gap:10px;margin-top:22px}.action-buttons button{display:flex;align-items:center;flex-direction:column;padding:17px 6px 15px;gap:9px;min-height:125px;border-bottom-width:4px;border-radius:17px}.action-symbol{color:#52627a;font-size:26px;line-height:1}.action-buttons strong{font-size:15px}.action-buttons small{font-size:11px;font-weight:400;color:#647086}.action-buttons button.chosen{border-color:var(--orange);color:var(--orange);background:#fff4eb}.action-buttons button.chosen:after{content:'✓';position:absolute;background:var(--orange);color:#fff;border-radius:50%;height:20px;width:20px;display:grid;place-items:center;transform:translate(42px,-25px)}.action-buttons button:disabled:not(.chosen){opacity:.68}.match-result{margin:24px auto 0;text-align:center;width:min(100%,480px)}.match-result h2{font-size:38px;margin:0}.match-result[data-result=win] h2{color:#15803d}.finish-reason,.save-status,.rematch-status{font-size:13px;color:#647086}.result-stats{margin:16px 0 22px;font-size:15px}.match-result .split{margin-top:16px}dialog{max-width:560px;width:calc(100% - 32px);border:1px solid var(--line);border-radius:24px;padding:24px;box-shadow:0 25px 70px #17203330;color:#172033}dialog::backdrop{background:#17203370}.dialog-top{display:flex;align-items:center;justify-content:space-between}.dialog-top h2{font-size:21px;margin:0}.rule-row{display:grid;grid-template-columns:100px 1fr;gap:12px;font-size:13px;padding:12px 0;border-bottom:1px solid var(--line)}dialog p{font-size:13px;line-height:1.8;color:#596477}@keyframes charge{0%,100%{opacity:0;transform:scale(.8)}40%,70%{opacity:.22;transform:scale(1.2)}}@keyframes blast{0%,20%{opacity:0;transform:translateX(0)}35%,60%{opacity:1;transform:translateX(calc(var(--direction,1)*100px))}100%{opacity:0;transform:translateX(calc(var(--direction,1)*240px))}}@keyframes guard{0%,100%{opacity:0}30%,75%{opacity:.65}}@keyframes dodge{0%,100%{opacity:1;transform:translateX(0)}35%,65%{opacity:.08;transform:translateX(35px)}}@keyframes hit{0%,40%,100%{transform:translateX(0)}50%,70%{transform:translateX(9px);opacity:.5}60%,80%{transform:translateX(-9px)}} +@media(max-width:600px){.app{padding:0 20px}header{height:78px}.brand{font-size:18px;gap:8px}.header-tools{gap:5px}#connection{font-size:11px}main{padding:32px 0 22px}.home h1{font-size:38px;margin:20px 0 28px}.stat-mode{padding:17px 20px}.home-emblem span{width:68px;height:68px;font-size:40px}.arena{min-height:270px;border-radius:22px}.avatar{width:120px;height:140px}.fighter{width:42%}.fighter-name{font-size:15px}.action-buttons{grid-template-columns:repeat(6,1fr);gap:8px;margin-top:16px}.action-buttons button{grid-column:span 2;min-height:106px;padding:13px 3px;gap:7px}.action-buttons button:nth-child(4){grid-column:2/span 2}.action-buttons strong{font-size:13px}.action-buttons small{font-size:10px}.action-symbol{font-size:23px}.turn-heading{font-size:22px}.match-result h2{font-size:30px}.room-code{font-size:36px;padding:24px 16px;letter-spacing:6px}.split button{padding:12px}.rule-row{grid-template-columns:80px 1fr}.clash-label{font-size:14px;padding:6px}.notice{font-size:12px;margin-top:16px}} +@media(prefers-reduced-motion:reduce){*,*:before,*:after{animation-duration:.01ms!important;transition:none!important}.fighter[data-action] .aura{opacity:.15}.fighter[data-action=guard] .shield{opacity:.6}.fighter[data-action=blast] .projectile,.fighter[data-action=beam] .projectile{opacity:1}.fighter[data-action=dodge] .avatar{opacity:.5}} diff --git a/src/client/ui-theme.css b/src/client/ui-theme.css new file mode 100644 index 0000000..c454dab --- /dev/null +++ b/src/client/ui-theme.css @@ -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; } +} diff --git a/src/server/admin.ts b/src/server/admin.ts new file mode 100644 index 0000000..47decc6 --- /dev/null +++ b/src/server/admin.ts @@ -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, 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) }; + }); + }); +} diff --git a/src/server/analytics-report.ts b/src/server/analytics-report.ts new file mode 100644 index 0000000..97f2419 --- /dev/null +++ b/src/server/analytics-report.ts @@ -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 = (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>(); + 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, 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 = { 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('analytics_events').aggregate([ + { $match: { occurredAt: { $gte: cutoff, $lt: now }, environment: 'production' } }, + { $sort: { occurredAt: 1, _id: 1 } }, { $project: { expiresAt: 0 } }, + ]).toArray(); + const players = await db.collection('players').find({}, { projection: { _id: 1, firstVisitAt: 1 } }).toArray(); + const playerById = new Map(players.map(p => [p._id, p])); + const sessions = await db.collection('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; players: Set; matches: Set; pageViews: number }>(); + const hourly = new Map; matches: Set }>(); + 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(); + 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; 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>((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), + }; +} diff --git a/src/server/analytics-store.ts b/src/server/analytics-store.ts new file mode 100644 index 0000000..eb6f819 --- /dev/null +++ b/src/server/analytics-store.ts @@ -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; + insert(event: AnalyticsEvent): Promise; + sample(now: Date, presence: Presence, environment: string, quality?: { restartedAt: Date; failures: number; dropped: number; rateLimited: number }): Promise; + cleanup(now: Date): Promise; +} +export async function createAnalyticsStore(db: Db): Promise { + const players = db.collection('players'); + const sessions = db.collection('analytics_sessions'); + const events = db.collection('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) }); }, + }; +} diff --git a/src/server/analytics.ts b/src/server/analytics.ts new file mode 100644 index 0000000..339dd60 --- /dev/null +++ b/src/server/analytics.ts @@ -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 : {}; + 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; waiters: { resolve: () => void; reject: () => void }[] } +export class Analytics { + states = new Map(); + queue: Job[] = []; running = false; closed = false; timer?: any; sampleTimer?: any; + httpRequests = 0; + lastCleanup = 0; + windows = new Map(); + 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; + 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(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('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): Promise { + 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((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(); + } +} diff --git a/src/server/clock.ts b/src/server/clock.ts new file mode 100644 index 0000000..a5f9517 --- /dev/null +++ b/src/server/clock.ts @@ -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 }; diff --git a/src/server/create-server.ts b/src/server/create-server.ts new file mode 100644 index 0000000..0f78da0 --- /dev/null +++ b/src/server/create-server.ts @@ -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 { + 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(done => io.close(() => done())); + }); + app.addHook('onClose', async () => { await store.close(); }); + return app; +} diff --git a/src/server/errors.ts b/src/server/errors.ts new file mode 100644 index 0000000..46e0b17 --- /dev/null +++ b/src/server/errors.ts @@ -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); } diff --git a/src/server/game-api.ts b/src/server/game-api.ts new file mode 100644 index 0000000..93743f5 --- /dev/null +++ b/src/server/game-api.ts @@ -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) { + 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 (request: FastifyRequest, work: (auth: GameSessionAuth) => Promise) => { + 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)); +} diff --git a/src/server/game-session-store.ts b/src/server/game-session-store.ts new file mode 100644 index 0000000..2967e66 --- /dev/null +++ b/src/server/game-session-store.ts @@ -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 { requestId: string; expectedPreviousSeq: number } +export async function createGameSessionStore(db: Db, now: () => number = Date.now, environment = 'production') { + const sessions = db.collection('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 { + // 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 { + 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('player_profiles').findOne({ _id: before.playerId }) : null; + const ownAppearance = normalizeAppearance(profile?.appearance); + let appearances: Pair = [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('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 { + 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 = { + 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>; diff --git a/src/server/index.ts b/src/server/index.ts new file mode 100644 index 0000000..06277f1 --- /dev/null +++ b/src/server/index.ts @@ -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(); }); diff --git a/src/server/ranked-api.ts b/src/server/ranked-api.ts new file mode 100644 index 0000000..b78c326 --- /dev/null +++ b/src/server/ranked-api.ts @@ -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); + }); +} diff --git a/src/server/ranked-service.ts b/src/server/ranked-service.ts new file mode 100644 index 0000000..f738433 --- /dev/null +++ b/src/server/ranked-service.ts @@ -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; appearances: Pair; 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; appearances: Pair; engine: MatchEngine; + actions: Pair; departed: Set; timer?: ReturnType; + terminal?: TerminalMatch; saved: boolean; finishedExpiresAt?: Pair; +} +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('player_profiles'); + const results = db.collection('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(); + const byPlayer = new Map(); + const matches = new Set(); + let tail: Promise = Promise.resolve(), closed = false; + function serial(work: () => Promise): Promise { + 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('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('game_sessions').find({ playerId, expiresAt: { $gt: new Date(clock.now()) } }).toArray(); + if (sessions.some(session => session.activeMatch) || await db.collection('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 { + 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 { + 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 { + 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 = {}; + 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 { + if (closed) throw new ApiError(503, 'RANKED_CLOSED'); + await readProfile(playerId); + const totals = new Map(); + 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 { + return serial(async () => { expireQueue(); if (queue.has(playerId) || byPlayer.has(playerId)) throw new ApiError(409, 'MATCH_PENDING'); }); + }, + withAvailable(playerId: string, work: () => Promise): Promise { + return serial(async () => { + if (queue.has(playerId) || byPlayer.has(playerId)) throw new ApiError(409, 'MATCH_PENDING'); + return work(); + }); + }, + status(playerId: string, gameSessionId: string): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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>; diff --git a/src/server/rooms.ts b/src/server/rooms.ts new file mode 100644 index 0000000..cfa7ab7 --- /dev/null +++ b/src/server/rooms.ts @@ -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; +} +export async function createRoomStore(db: Db, now: () => number = Date.now) { + const rooms = db.collection('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('game_sessions').findOne({ _id: sessionId }); + const profile = session?.playerId ? await db.collection('player_profiles').findOne({ _id: session.playerId }) : null; + return normalizeAppearance(profile?.appearance); + } + return { + async create(sessionId: string, requestId: string): Promise { + 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 { + 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 { + 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('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>; diff --git a/src/server/signaling.ts b/src/server/signaling.ts new file mode 100644 index 0000000..5a021e8 --- /dev/null +++ b/src/server/signaling.ts @@ -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 { + 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; diff --git a/src/server/stats-store.ts b/src/server/stats-store.ts new file mode 100644 index 0000000..1749ff7 --- /dev/null +++ b/src/server/stats-store.ts @@ -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; + findGuest(token: string): Promise; + close(): Promise; +} +const hash = (token: string) => createHash('sha256').update(token).digest('hex'); +export async function createStatsStore(options: { mongoUri: string; databaseName: string }): Promise { + const client = new MongoClient(options.mongoUri, { serverSelectionTimeoutMS: 5000 }); + try { + await client.connect(); + const db = client.db(options.databaseName); + const players = db.collection('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; } +} diff --git a/src/shared/analytics-types.ts b/src/shared/analytics-types.ts new file mode 100644 index 0000000..9044bf4 --- /dev/null +++ b/src/shared/analytics-types.ts @@ -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 } diff --git a/src/shared/bot.ts b/src/shared/bot.ts new file mode 100644 index 0000000..254c61a --- /dev/null +++ b/src/shared/bot.ts @@ -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)))]; +} diff --git a/src/shared/cosmetics.ts b/src/shared/cosmetics.ts new file mode 100644 index 0000000..c2a7159 --- /dev/null +++ b/src/shared/cosmetics.ts @@ -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; + 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); +} diff --git a/src/shared/match-engine.ts b/src/shared/match-engine.ts new file mode 100644 index 0000000..ff840b6 --- /dev/null +++ b/src/shared/match-engine.ts @@ -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): 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), now = config.clock.now(); + state.reveal = { matchId: config.matchId, turnId: state.turnId, actions: [...actions] as Pair, + 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; diff --git a/src/shared/p2p-types.ts b/src/shared/p2p-types.ts new file mode 100644 index 0000000..eda3398 --- /dev/null +++ b/src/shared/p2p-types.ts @@ -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 } +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; + appearances?: Pair; +} +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 { + clockNow: number; aborted: boolean; + appearances?: Pair; + ranked?: boolean; + finished?: Omit & { 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; 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() }; +} diff --git a/src/shared/peer-protocol.ts b/src/shared/peer-protocol.ts new file mode 100644 index 0000000..174d71d --- /dev/null +++ b/src/shared/peer-protocol.ts @@ -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 { + 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; + send(message: PeerMessage): void; onResolved(actions: Pair): 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 = [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; diff --git a/src/shared/ranked-types.ts b/src/shared/ranked-types.ts new file mode 100644 index 0000000..46da60d --- /dev/null +++ b/src/shared/ranked-types.ts @@ -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 } diff --git a/src/shared/rules.ts b/src/shared/rules.ts new file mode 100644 index 0000000..c344230 --- /dev/null +++ b/src/shared/rules.ts @@ -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, actions: Pair): TurnResolution { + if (!isLegalAction(actions[0], energy[0]) || !isLegalAction(actions[1], energy[1])) throw new Error('INVALID_ACTION'); + const [a, b] = actions as Pair; + 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; + return { energy: after, winnerIndex, hitIndex }; +} diff --git a/src/shared/types.ts b/src/shared/types.ts new file mode 100644 index 0000000..7926f1c --- /dev/null +++ b/src/shared/types.ts @@ -0,0 +1,28 @@ +import type { ACTIONS } from '../../constants/gameplay.js'; +export type ActionId = keyof typeof ACTIONS; +export type Pair = [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; winnerIndex: 0 | 1 | null; hitIndex: 0 | 1 | null } +export interface TurnReveal extends TurnResolution { + matchId: string; turnId: number; actions: Pair; energyBefore: Pair; + revealedAt: number; revealEndsAt: number; +} +export interface ModeStats { current: number; best: number; wins: number; losses: number; draws: number } +export type PlayerStats = Record; +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; turnId: number; serverNow: number; startsAt: number; deadline: number; + ownAction: ActionId | null; reveal?: TurnReveal; finished?: MatchFinished; + rematch: Pair; 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 = { ok: true; data: T } | { ok: false; code: string; message: string }; diff --git a/tests/admin.test.ts b/tests/admin.test.ts new file mode 100644 index 0000000..ce6f1c1 --- /dev/null +++ b/tests/admin.test.ts @@ -0,0 +1,46 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { randomBytes, randomUUID } from 'node:crypto'; +import { MongoClient } from 'mongodb'; +import { createGameServer } from '../src/server/create-server.js'; +import { ManualClock } from './helpers.js'; + +test('admin keeps auth, visitor metadata and current game sessions without historical matches', { skip: !process.env.TEST_MONGODB_URI }, async () => { + const databaseName = 'dragon_ball_test_admin_' + randomUUID().replaceAll('-', ''), clock = new ManualClock(); + const app = await createGameServer({ mongoUri: process.env.TEST_MONGODB_URI!, databaseName, clientDirectory: 'dist/client', adminUser: 'operator', adminPassword: 'test-password-long', clock }); + const client = await new MongoClient(process.env.TEST_MONGODB_URI!).connect(); + const headers = { authorization: 'Basic ' + Buffer.from('operator:test-password-long').toString('base64') }; + try { + assert.equal((await app.inject('/api/admin/analytics/report')).statusCode, 401); + assert.equal((await app.inject('/admin/analytics')).statusCode, 401); + assert.equal((await app.inject('/admin/analytics/index.html')).statusCode, 404); + assert.equal((await app.inject({ url: '/api/admin/analytics/report?start=bad', headers })).statusCode, 400); + const create = async () => (await app.inject({ method: 'POST', url: '/api/game-sessions', payload: { createRequestId: randomBytes(32).toString('base64url') } })).json().data; + const a = await create(), b = await create(); + const post = (s: typeof a, url: string, payload: any) => app.inject({ method: 'POST', url, headers: { authorization: 'Bearer ' + s.token, 'x-game-session': s.gameSessionId }, payload }); + const data = { eventId: 'page', eventType: 'page_view', screen: 'home', metadata: { device: 'mobile', browser: 'Safari 18', referrer: 'https://example.com/a?guestToken=secret', source: 'made-up' } }; + await post(a, '/api/analytics/activity', { visible: true }); + for (const s of [a, a, b]) assert.equal((await post(s, '/api/analytics/events', { events: [data] })).statusCode, 200); + assert.equal((await post(a, '/api/analytics/events', { events: [{ ...data, eventId: 'bad', code: 'free-text-secret' }] })).statusCode, 400); + await post(a, '/api/analytics/events', { events: [{ eventId: 'start', eventType: 'game_started', matchId: 'local-game', mode: 'bot' }] }); + const match = (await post(a, '/api/game-session/matches', { requestId: 'register', matchId: 'local-game', mode: 'bot', expectedPreviousSeq: 0 })).json().data; + await post(a, '/api/game-session/results', { ...match, outcome: 'win', reason: 'hit' }); + clock.time += 100; + const report = (await app.inject({ url: '/api/admin/analytics/report', headers })).json(); + assert.equal(report.overview.visitors, 2); assert.equal(report.overview.pageViews, 2); + assert.equal(report.clientGameplay.players, 1); assert.equal(report.clientGameplay.startedMatches, 1); + assert.ok(!('gameplay' in report)); + assert.equal(report.live.active, 2); assert.equal(report.live.connected, null); + assert.equal(report.live.matches, null); assert.equal(report.live.playingPeople, null); + assert.equal(report.live.source, 'activity_reports'); assert.ok(report.live.observedAt); + await post(b, '/api/analytics/activity', { visible: false }); + assert.equal((await app.inject({ url: '/api/admin/analytics/live', headers })).json().live.active, 1); + const detail = (await app.inject({ url: '/api/admin/analytics/users/' + a.playerId, headers })).json(); + assert.equal(detail.gameSessions.length, 1); assert.equal(detail.gameSessions[0].stats.bot.wins, 1); + assert.ok(!('stats' in detail)); assert.ok(!('matches' in detail)); assert.ok(!('firstMatchAt' in detail)); + const body = JSON.stringify(detail); assert.ok(!body.includes(a.token)); assert.ok(!body.includes('tokenHash')); assert.ok(!body.includes('guestTokenHash')); + const events = JSON.stringify(await client.db(databaseName).collection('analytics_events').find().toArray()); + assert.ok(!events.includes('guestToken')); assert.ok(!events.includes('made-up')); + assert.ok(events.includes('example.com')); assert.ok(events.includes('other')); + } finally { await app.close(); try { await client.db(databaseName).dropDatabase(); } finally { await client.close(); } } +}); diff --git a/tests/analytics.test.ts b/tests/analytics.test.ts new file mode 100644 index 0000000..0da291b --- /dev/null +++ b/tests/analytics.test.ts @@ -0,0 +1,53 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { randomUUID } from 'node:crypto'; +import { MongoClient } from 'mongodb'; +import { createAnalyticsStore } from '../src/server/analytics-store.js'; +import { kstDay, ratio, retention } from '../src/server/analytics-report.js'; +import { createStatsStore } from '../src/server/stats-store.js'; +import { RETENTION_MS, VISIT_SESSION_MS } from '../constants/analytics.js'; + +test('KST boundaries, zero denominators, calendar cohorts', () => { + assert.equal(kstDay(new Date('2026-10-11T15:00:00Z')), '2026-10-12'); + assert.equal(kstDay(new Date('2026-10-11T14:59:59Z')), '2026-10-11'); + assert.deepEqual(ratio(0, 0), { numerator: 0, denominator: 0, percent: null }); + const visits = [{ playerId: 'a', day: '2026-10-01' }, { playerId: 'a', day: '2026-10-02' }, { playerId: 'b', day: '2026-10-02' }]; + const cohorts = retention([{ playerId: 'a', day: '2026-10-01' }, { playerId: 'b', day: '2026-10-02' }], visits, new Date('2026-10-03T00:00:00Z')); + assert.deepEqual(cohorts[0].d1, { numerator: 1, denominator: 1, percent: 100 }); + assert.equal(cohorts[0].d7, null); assert.equal(cohorts[1].d1, null); + assert.deepEqual(retention([{ playerId: 'a', day: '2026-09-30' }], [{ playerId: 'a', day: '2026-10-07' }], new Date('2026-10-08T00:00:00Z'))[0].d7, { numerator: 1, denominator: 1, percent: 100 }); + +}); + +test('atomic visit session rotation, duplicate collection and safe metadata on actual MongoDB', { skip: !process.env.TEST_MONGODB_URI }, async () => { + const databaseName = `dragon_ball_test_analytics_${randomUUID().replaceAll('-', '')}`; + const stats = await createStatsStore({ mongoUri: process.env.TEST_MONGODB_URI!, databaseName }); + try { + const store = await createAnalyticsStore(stats.db); + const guest = await stats.createGuest(); + const t = new Date('2026-10-01T00:00:00Z'); + const metadata = { device: 'mobile', browser: 'Chrome 141', source: 'direct_or_unknown', invitation: false }; + const first = await store.touch(guest.playerId, t, true, metadata); + const second = await store.touch(guest.playerId, new Date(+t + 30000), true, metadata); + assert.equal(second.sessionId, first.sessionId); assert.equal(second.activeMs, 30000); + await store.touch(guest.playerId, new Date(+t + 40000), false, metadata); + const visible = await store.touch(guest.playerId, new Date(+t + 60000), true, metadata); + assert.equal(visible.activeMs, 30000); + const [a, b] = await Promise.all([store.touch(guest.playerId, new Date(+t + 60000 + VISIT_SESSION_MS), true, metadata), store.touch(guest.playerId, new Date(+t + 60000 + VISIT_SESSION_MS), true, metadata)]); + assert.equal(a.sessionId, b.sessionId); assert.notEqual(a.sessionId, first.sessionId); + await store.touch(guest.playerId, new Date('2026-10-01T15:00:00Z'), true, metadata); + const acrossDay = await store.touch(guest.playerId, new Date('2026-10-01T15:10:00Z'), true, metadata); + assert.deepEqual(acrossDay.activeDays, ['2026-10-02']); + assert.equal(await stats.db.collection('analytics_sessions').countDocuments(), 3); + const event = { _id: 'event-one', playerId: guest.playerId, sessionId: first.sessionId, occurredAt: t, eventType: 'page_view', environment: 'production', schemaVersion: 1, appVersion: '0.1.0', metadata }; + await Promise.all([store.insert(event), store.insert(event)]); + assert.equal(await stats.db.collection('analytics_events').countDocuments(), 1); + assert.equal(+(await stats.db.collection('analytics_events').findOne())!.expiresAt, +t + RETENTION_MS); + for (const name of ['analytics_events', 'analytics_sessions', 'analytics_presence_samples']) { + assert.ok((await stats.db.collection(name).indexes()).some(i => i.key.expiresAt === 1 && i.expireAfterSeconds === 0)); + } + await store.cleanup(new Date(+t + RETENTION_MS + VISIT_SESSION_MS + 86400000)); + const retained = await stats.db.collection('players').findOne({ _id: guest.playerId as any }); + assert.ok(retained); assert.equal(retained?.visit, undefined); assert.ok(retained?.guestTokenHash); + } finally { await stats.db.dropDatabase(); await stats.close(); } +}); diff --git a/tests/bot.test.ts b/tests/bot.test.ts new file mode 100644 index 0000000..ef0e136 --- /dev/null +++ b/tests/bot.test.ts @@ -0,0 +1,11 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { chooseBotAction } from '../src/shared/bot.js'; +import { isLegalAction } from '../src/shared/rules.js'; +test('bot uses only legal actions from public energy, with repeatable injected randomness', () => { + for (const own of [0, 1, 2, 3, 10]) for (const opponent of [0, 1, 3]) for (const r of [0, .1, .5, .9, .999]) { + assert.ok(isLegalAction(chooseBotAction(own, opponent, () => r), own)); + } + assert.equal(chooseBotAction(0, 0, () => 0), 'charge'); + assert.notEqual(chooseBotAction(3, 3, () => 0), chooseBotAction(3, 3, () => .999)); +}); diff --git a/tests/client-analytics.test.ts b/tests/client-analytics.test.ts new file mode 100644 index 0000000..12c6829 --- /dev/null +++ b/tests/client-analytics.test.ts @@ -0,0 +1,29 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createClientAnalytics } from '../src/client/analytics.js'; + +test('client sends batches at 20 events or five seconds and limits activity including visibility toggles', async t => { + t.mock.timers.enable({ apis: ['setInterval', 'Date'], now: 1000 }); + const doc = new EventTarget() as EventTarget & { hidden: boolean; referrer: string }; + doc.hidden = false; doc.referrer = ''; + Object.defineProperty(globalThis, 'document', { value: doc, configurable: true }); + Object.defineProperty(globalThis, 'location', { value: { search: '' }, configurable: true }); + const batches: any[][] = [], activities: boolean[] = []; + const analytics = createClientAnalytics({ + events: async events => { batches.push(events); }, activity: async visible => { activities.push(visible); }, + }); + const flush = async () => { for (let i = 0; i < 5; i++) await Promise.resolve(); }; + try { + analytics.ready(); + await flush(); + assert.equal(batches.length, 0); + for (let i = 0; i < 19; i++) analytics.screen('match'); + await flush(); assert.equal(batches[0].length, 20); + analytics.screen('result'); + t.mock.timers.tick(4999); await flush(); assert.equal(batches.length, 1); + t.mock.timers.tick(1); await flush(); assert.equal(batches[1].length, 1); + for (let i = 0; i < 10; i++) { doc.hidden = true; doc.dispatchEvent(new Event('visibilitychange')); doc.hidden = false; doc.dispatchEvent(new Event('visibilitychange')); } + await flush(); assert.deepEqual(activities, [true, false]); + t.mock.timers.tick(55000); await flush(); assert.deepEqual(activities, [true, false, true]); + } finally { analytics.destroy(); Reflect.deleteProperty(globalThis, 'document'); Reflect.deleteProperty(globalThis, 'location'); } +}); diff --git a/tests/cosmetics.test.ts b/tests/cosmetics.test.ts new file mode 100644 index 0000000..c6ff620 --- /dev/null +++ b/tests/cosmetics.test.ts @@ -0,0 +1,220 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { fighterArt, finisherArt } from '../src/client/fighter-art.js'; +import { appearanceKey, availableColorIds, availableEquipmentIds, DEFAULT_APPEARANCE, normalizeAppearance, type FighterAppearance } from '../src/shared/cosmetics.js'; +import { createMatchView } from '../src/client/match-view.js'; +import { emptyPlayerStats, type LocalMatchState } from '../src/shared/p2p-types.js'; + +test('invalid appearance fields default independently without accepting arbitrary style input', () => { + for (const value of [undefined, null, false, 1, 'blue', [], {}]) { + assert.deepEqual(normalizeAppearance(value), { characterId: 0, colorId: 'white', skinTone: 'light', hairStyle: 'black', equipment: [] }); + } + assert.deepEqual(DEFAULT_APPEARANCE, { characterId: 0, colorId: 'white', skinTone: 'light', hairStyle: 'black', equipment: [] }); + assert.deepEqual(normalizeAppearance({ characterId: '1', colorId: 'blue' }), { ...DEFAULT_APPEARANCE, characterId: 0, colorId: 'blue' }); + assert.deepEqual(normalizeAppearance({ characterId: 1, colorId: 'red;--hair:transparent' }), { ...DEFAULT_APPEARANCE, characterId: 1, colorId: 'white', skinTone: 'tan', hairStyle: 'white' }); + assert.deepEqual(normalizeAppearance({ characterId: 2, colorId: null }), { ...DEFAULT_APPEARANCE, characterId: 0, colorId: 'white' }); + assert.deepEqual(normalizeAppearance({ characterId: 1, colorId: 'black' }), { ...DEFAULT_APPEARANCE, characterId: 1, colorId: 'black', skinTone: 'tan', hairStyle: 'white' }); +}); + +test('equipment defaults to bare parts, normalizes independently, and unlocks at approved thresholds', () => { + const old = { characterId: 1, colorId: 'red' }; + assert.deepEqual(normalizeAppearance(old), { ...old, skinTone: 'tan', hairStyle: 'white', equipment: [] }); + assert.deepEqual(normalizeAppearance({ ...old, hairStyle: 'bald', equipment: ['headband', 'bad', 'shoes', 'shoes'] }), { ...old, skinTone: 'tan', hairStyle: 'bald', equipment: ['shoes', 'headband'] }); + assert.deepEqual(normalizeAppearance({ ...old, hairStyle: '', equipment: 'shoes' }), { ...old, skinTone: 'tan', hairStyle: 'white', equipment: [] }); + assert.deepEqual(availableEquipmentIds({ wins: 4, best: 2 }), []); + assert.deepEqual(availableEquipmentIds({ wins: 5, best: 2 }), ['shoes']); + assert.deepEqual(availableEquipmentIds({ wins: 0, best: 3 }), ['wristbands']); + assert.deepEqual(availableEquipmentIds({ wins: 19, best: 3 }), ['shoes', 'wristbands']); + assert.deepEqual(availableEquipmentIds({ wins: 20, best: 3 }), ['shoes', 'wristbands', 'headband']); + const first = normalizeAppearance(undefined); first.equipment!.push('shoes'); + assert.deepEqual(normalizeAppearance(undefined).equipment, []); + assert.notEqual(appearanceKey(DEFAULT_APPEARANCE), appearanceKey({ ...DEFAULT_APPEARANCE, hairStyle: 'bald' })); + assert.notEqual(appearanceKey(DEFAULT_APPEARANCE), appearanceKey({ ...DEFAULT_APPEARANCE, equipment: ['shoes'] })); +}); + +test('skin and hair migrate from legacy characters and remain independent of body identity', () => { + for (const characterId of [0, 1] as const) { + const legacy = { characterId, colorId: 'blue', equipment: ['shoes'] }; + for (const hairStyle of [undefined, 'default', 'invalid']) { + const value = normalizeAppearance({ ...legacy, hairStyle, skinTone: 'invalid' }); + assert.equal(value.skinTone, characterId === 0 ? 'light' : 'tan'); + assert.equal(value.hairStyle, characterId === 0 ? 'black' : 'white'); + assert.deepEqual(value.equipment, ['shoes']); + } + for (const skinTone of ['light', 'tan'] as const) for (const hairStyle of ['black', 'white', 'bald'] as const) { + assert.deepEqual(normalizeAppearance({ ...legacy, skinTone, hairStyle }), { ...legacy, skinTone, hairStyle }); + } + } + const light = normalizeAppearance({ characterId: 1, colorId: 'white', skinTone: 'light', hairStyle: 'black' }); + assert.notEqual(appearanceKey(light), appearanceKey({ ...light, skinTone: 'tan' })); + assert.notEqual(appearanceKey(light), appearanceKey({ ...light, hairStyle: 'white' })); +}); + +test('colors unlock at inclusive win or best-streak boundaries, with white always available', () => { + const cases: [number, number, string[]][] = [ + [0, 0, ['white']], [4, 2, ['white']], + [5, 0, ['white', 'orange']], [19, 0, ['white', 'orange']], + [20, 0, ['white', 'orange', 'blue']], [49, 0, ['white', 'orange', 'blue']], + [50, 0, ['white', 'orange', 'blue', 'red']], + [0, 3, ['white', 'green']], [0, 4, ['white', 'green']], + [0, 5, ['white', 'green', 'purple']], [0, 9, ['white', 'green', 'purple']], + [0, 10, ['white', 'green', 'purple', 'black']], + [50, 10, ['white', 'orange', 'blue', 'red', 'green', 'purple', 'black']], + ]; + for (const [wins, best, expected] of cases) assert.deepEqual(availableColorIds({ wins, best }), expected); +}); + +test('eyewear uses permanent best-streak boundaries and a single canonical slot', () => { + for (const [best, expected] of [[49, []], [50, ['glasses']], [99, ['glasses']], [100, ['glasses', 'sunglasses']]] as const) { + const stats = { wins: 1000, best, current: 0 }; + assert.deepEqual(availableEquipmentIds(stats).filter(id => id === 'glasses' || id === 'sunglasses'), expected); + } + const inflatedCurrent = { wins: 1000, best: 49, current: 1000 }; + assert.deepEqual(availableEquipmentIds(inflatedCurrent), ['shoes', 'wristbands', 'headband']); + const appearance = normalizeAppearance({ characterId: 1, colorId: 'black', hairStyle: 'bald', + equipment: ['sunglasses', 'headband', 'glasses', 'shoes', 'sunglasses', 'invalid'], + }); + assert.deepEqual(appearance.equipment, ['shoes', 'headband', 'glasses']); + assert.deepEqual(normalizeAppearance({ equipment: ['sunglasses', 'sunglasses'] }).equipment, ['sunglasses']); + // Normalization permits locked previews; availability is enforced only when saving. + assert.notEqual(appearanceKey(DEFAULT_APPEARANCE), appearanceKey(normalizeAppearance({ equipment: ['glasses'] }))); + assert.notEqual(appearanceKey(normalizeAppearance({ equipment: ['glasses'] })), appearanceKey(normalizeAppearance({ equipment: ['sunglasses'] }))); +}); + +test('character geometry and hair follow the selected character independently of player slot', () => { + for (const slot of [0, 1]) { + for (const closeup of [false, true]) { + const veteran = fighterArt(slot, closeup, { characterId: 1, colorId: 'white' }); + assert.match(veteran, /--hair:#d7d9cf/); + assert.match(veteran, /m110 36-7-12 14 2/); + assert.match(veteran, /--suit:#f0eee5;/); + const original = fighterArt(slot, closeup, { characterId: 0, colorId: 'white' }); + assert.match(original, /--hair:#242b30/); + assert.match(original, /m113 33-15-7 14-3/); + assert.match(original, /--suit:#f0eee5;/); + } + } +}); + +test('robe previews do not change character identity and unconfigured art keeps legacy palettes', () => { + assert.match(fighterArt(0), /--suit:#aa5740;/); + assert.match(fighterArt(1), /--suit:#455862;/); + for (const characterId of [0, 1] as const) { + const preview = fighterArt(0, false, { characterId, colorId: 'black' }); + assert.match(preview, /--suit:#34383e;/); + assert.match(preview, characterId === 1 ? /--hair:#d7d9cf/ : /--hair:#242b30/); + for (const pose of ['ready', 'charge', 'attack', 'guard', 'dodge', 'win', 'loss', 'draw', 'hit']) { + assert.ok(preview.includes(`class="pose pose-${pose}"`)); + } + for (const face of ['ready', 'focus', 'win', 'loss', 'draw', 'shock']) { + assert.ok(preview.includes(`class="face face-${face}"`)); + } + } +}); + +test('blast and beam finishers use the attacker and victim appearances for either hit slot', () => { + const appearances: [FighterAppearance, FighterAppearance] = [ + { characterId: 1, colorId: 'white' }, { characterId: 0, colorId: 'black' }, + ]; + const check = (markup: string, slot: number) => { + assert.match(markup, slot === 0 ? /--suit:#f0eee5;/ : /--suit:#34383e;/); + assert.match(markup, slot === 0 ? /--hair:#d7d9cf/ : /--hair:#242b30/); + assert.match(markup, slot === 0 ? /m110 36-7-12 14 2/ : /m113 33-15-7 14-3/); + }; + for (const hit of [0, 1] as const) { + const attacker = 1 - hit; + for (const attack of ['blast', 'beam'] as const) { + const html = finisherArt(hit, attack, appearances); + const drawings = [...html.matchAll(//g)].map(match => match[0]); + const slots = attack === 'beam' ? [attacker, hit, attacker, hit, hit] : [attacker, 0, 1]; + assert.equal(drawings.length, slots.length); + drawings.forEach((drawing, i) => check(drawing, slots[i])); + if (attack === 'blast') { + const fallen = html.match(/]*style="([^"]+)"`))![1]; + assert.match(style, hit === 0 ? /--suit:#f0eee5;/ : /--suit:#34383e;/); + assert.match(style, hit === 0 ? /--hair:#d7d9cf/ : /--hair:#242b30/); + } + } + } + } +}); + +// Small DOM boundary for exercising the real view's updates without a browser dependency. +function viewHarness(t: import('node:test').TestContext) { + class Element { + dataset: Record = {}; + textContent = ''; innerHTML = ''; disabled = false; hidden = false; + children = new Map(); + style = { setProperty() {}, removeProperty() {} }; + classList = { toggle() {} }; + setAttribute() {} + querySelector(selector: string): Element { + if (!this.children.has(selector)) this.children.set(selector, new Element()); + return this.children.get(selector)!; + } + querySelectorAll(selector: string) { return selector === '[data-fighter]' ? actors : []; } + } + const actors = [new Element(), new Element()]; + const container = new Element(); + const previous = Object.getOwnPropertyDescriptor(globalThis, 'window'); + Object.defineProperty(globalThis, 'window', { configurable: true, value: { + matchMedia: () => ({ matches: false, addEventListener() {}, removeEventListener() {} }), + } }); + const view = createMatchView(container as unknown as HTMLElement, async () => true, () => {}, () => {}); + t.after(() => { + view.destroy(); + if (previous) Object.defineProperty(globalThis, 'window', previous); + else Reflect.deleteProperty(globalThis, 'window'); + }); + const now = Date.now(); + const state: LocalMatchState = { + matchId: 'appearance-test', mode: 'friend', phase: 'selecting', version: 1, selfIndex: 0, + energy: [3, 3], turnId: 1, clockNow: now, startsAt: now, deadline: now + 10000, + ownAction: null, rematch: [false, false], opponentPresent: true, opponentLeft: false, + saveStatus: 'saved', aborted: false, + }; + return { view, container, actors, state }; +} + +test('state updates replace only changed fighter appearances and retain actor state', t => { + const { view, actors, state } = viewHarness(t); + state.appearances = [{ characterId: 1, colorId: 'white' }, { characterId: 0, colorId: 'black' }]; + view.update(state); + const first = actors[0].querySelector('.character-direction'); + const second = actors[1].querySelector('.character-direction'); + assert.match(first.innerHTML, /--hair:#d7d9cf/); + assert.match(second.innerHTML, /--hair:#242b30/); + // Mark existing SVG contents to detect an unwanted rebuild on equivalent state. + first.innerHTML += ''; second.innerHTML += ''; + view.update({ ...state, version: 2, appearances: [{ colorId: 'white', characterId: 1 }, { colorId: 'black', characterId: 0 }] }); + assert.ok(first.innerHTML.endsWith('')); + assert.ok(second.innerHTML.endsWith('')); + view.update({ ...state, version: 3, appearances: [{ characterId: 1, colorId: 'red' }, state.appearances[1]] }); + assert.match(first.innerHTML, /--suit:#ad4148;/); + assert.ok(!first.innerHTML.endsWith('')); + assert.ok(second.innerHTML.endsWith('')); + assert.equal(actors[0].dataset.locked, 'false'); +}); + +test('ranked results allow requeue after saving even when the opponent has left', t => { + const { view, container, state } = viewHarness(t); + state.ranked = true; state.phase = 'finished'; state.opponentLeft = true; state.rematch = [true, true]; + state.finished = { matchId: state.matchId, winnerIndex: 0, reason: 'timeout', stats: emptyPlayerStats(), revealEndsAt: state.clockNow, saved: false }; + view.update(state); + assert.equal(container.querySelector('.mode-label').textContent, '랭킹 대전'); + assert.equal(container.querySelector('.rematch').textContent, '다시 매칭'); + assert.equal(container.querySelector('.rematch').disabled, true); + state.finished.saved = true; + view.update({ ...state, version: 2 }); + assert.equal(container.querySelector('.rematch').disabled, false); + assert.equal(container.querySelector('.rematch-status').textContent, ''); + view.update({ ...state, version: 3, ranked: false }); + assert.equal(container.querySelector('.rematch').disabled, true); + assert.equal(container.querySelector('.rematch').textContent, '다시 대전'); + assert.equal(container.querySelector('.rematch-status').textContent, '상대가 나갔습니다.'); +}); diff --git a/tests/equipment-art.test.ts b/tests/equipment-art.test.ts new file mode 100644 index 0000000..ec21e8b --- /dev/null +++ b/tests/equipment-art.test.ts @@ -0,0 +1,314 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createHash } from 'node:crypto'; +import { readFileSync } from 'node:fs'; +import { fighterArt, finisherArt } from '../src/client/fighter-art.js'; +import { normalizeAppearance, type FighterAppearance } from '../src/shared/cosmetics.js'; +import { createMatchView } from '../src/client/match-view.js'; +import { createFinishCutscene } from '../src/client/finish-cutscene.js'; +import { emptyPlayerStats, type LocalMatchState } from '../src/shared/p2p-types.js'; +import { SKIN_TONES } from '../constants/cosmetics.js'; + +const allEquipment: FighterAppearance['equipment'] = ['shoes', 'wristbands', 'headband']; +const section = (html: string, className: string) => { + const start = html.indexOf(`class="${className}"`); + assert.notEqual(start, -1, className); + return html.slice(start, html.indexOf('', start)); +}; + +test('skin palette and hair geometry are independent of each other and historical body variants', () => { + for (const characterId of [0, 1] as const) { + for (const skinTone of ['light', 'tan'] as const) { + for (const hairStyle of ['black', 'white', 'bald'] as const) { + for (const closeup of [false, true]) { + const html = fighterArt(1 - characterId, closeup, { characterId, colorId: 'white', skinTone, hairStyle }); + const skin = SKIN_TONES.find(tone => tone.id === skinTone)!; + assert.ok(html.includes(`--skin:${skin.skin};--skin-shade:${skin.shade};--skin-light:${skin.light};`)); + assert.ok(html.includes('--suit:#f0eee5;'), 'robe color stays independent'); + assert.equal(html.includes('m110 36-7-12 14 2'), hairStyle === 'white'); + assert.equal(html.includes('M109 29 103 35 100 44 109 39 113 32Z'), hairStyle === 'black'); + assert.equal(html.includes('class="bald-scalp"'), hairStyle === 'bald'); + if (hairStyle !== 'bald') assert.ok(html.includes(hairStyle === 'white' + ? '--hair:#d7d9cf;--hair-shadow:#7e8d90' + : '--hair:#242b30;--hair-shadow:#454e53')); + else assert.ok(!html.includes('fill="var(--hair)"')); + assert.ok(html.includes(characterId === 1 + ? 'm110 77-28 9-5 20 18 28 8 34 50 4' + : 'm113 77-26 10-7 21 20 26 3 34 49 4'), 'body variant stays fixed'); + assert.equal(html.includes('m118 68 5 3m0-1 3 3'), characterId === 1, 'facial details stay fixed'); + } + } + } + } +}); + +test('unconfigured and legacy default hair retain each historical model appearance', () => { + for (const characterId of [0, 1] as const) { + const legacy = normalizeAppearance({ characterId, colorId: 'white', hairStyle: 'default' }); + assert.ok(fighterArt(characterId).includes(characterId === 1 ? '--skin:#b59173;' : '--skin:#d7ad8a;')); + assert.ok(fighterArt(1 - characterId, true, legacy).includes(`--skin:${SKIN_TONES[characterId].skin};`)); + for (const html of [fighterArt(characterId), fighterArt(1 - characterId, true, legacy)]) { + assert.ok(html.includes(characterId === 1 ? '--hair:#d7d9cf;' : '--hair:#242b30;')); + assert.ok(html.includes(characterId === 1 ? 'm110 36-7-12 14 2' : 'M109 29 103 35 100 44 109 39 113 32Z')); + } + assert.ok(fighterArt(characterId).includes(characterId === 1 ? '--suit:#455862;' : '--suit:#aa5740;')); + } +}); + +test('active finishers retain independent skin and hair in portraits, fallen heads and fragment palettes', () => { + const appearances: [FighterAppearance, FighterAppearance] = [ + { characterId: 0, colorId: 'white', skinTone: 'tan', hairStyle: 'white', equipment: ['glasses'] }, + { characterId: 1, colorId: 'white', skinTone: 'light', hairStyle: 'black', equipment: ['sunglasses'] }, + ]; + const check = (html: string, slot: number) => { + assert.ok(html.includes(`--skin:${SKIN_TONES[slot === 0 ? 1 : 0].skin};`)); + assert.ok(html.includes(slot === 0 ? '--hair:#d7d9cf;' : '--hair:#242b30;')); + assert.ok(html.includes(slot === 0 ? 'm110 36-7-12 14 2' : 'M109 29 103 35 100 44 109 39 113 32Z')); + }; + for (const hit of [0, 1] as const) { + for (const attack of ['blast', 'beam'] as const) { + const html = finisherArt(hit, attack, appearances); + const drawings = [...html.matchAll(//g)].map(match => match[0]); + const slots = attack === 'beam' ? [1 - hit, hit, 1 - hit, hit, hit] : [1 - hit, 0, 1]; + assert.equal(drawings.length, slots.length); + drawings.forEach((drawing, i) => check(drawing, slots[i])); + if (attack === 'blast') check(html.match(//)![0], hit); + else for (const name of ['beam-reaction-art', 'beam-dissolve-art']) { + const wrapper = html.match(new RegExp(`]+>`))![0]; + assert.ok(wrapper.includes(`--skin:${SKIN_TONES[hit === 0 ? 1 : 0].skin};`)); + assert.ok(wrapper.includes(hit === 0 ? '--hair:#d7d9cf;' : '--hair:#242b30;')); + } + } + } +}); + +test('eyewear is absent from unconfigured and default appearances for both fighters', () => { + for (const characterId of [0, 1] as const) { + for (const closeup of [false, true]) { + assert.ok(!fighterArt(characterId, closeup).includes('class="eyewear ')); + assert.ok(!fighterArt(characterId, closeup, { characterId, colorId: 'white' }).includes('class="eyewear ')); + } + assert.ok(!finisherArt(characterId, 'blast').includes('class="eyewear ')); + assert.ok(!finisherArt(characterId, 'beam').includes('class="eyewear ')); + } +}); + +test('selected clear and dark eyewear overlays every expression with either hair style and headband', () => { + for (const characterId of [0, 1] as const) { + for (const hairStyle of ['black', 'white', 'bald'] as const) { + for (const headband of [false, true]) { + for (const eyewear of ['glasses', 'sunglasses'] as const) { + for (const closeup of [false, true]) { + const equipment: FighterAppearance['equipment'] = headband ? ['headband', eyewear] : [eyewear]; + const html = fighterArt(1 - characterId, closeup, { characterId, colorId: 'white', hairStyle, equipment }); + const overlay = section(html, `eyewear eyewear-${eyewear}`); + assert.equal((html.match(/class="eyewear /g) ?? []).length, 1); + assert.ok(html.indexOf(`class="eyewear eyewear-${eyewear}"`) > html.indexOf('', html.indexOf('class="face face-shock"')), 'eyewear is above all expressions'); + assert.ok(!overlay.includes(' { + const appearances: [FighterAppearance, FighterAppearance] = [ + { characterId: 1, colorId: 'white', equipment: ['glasses'] }, + { characterId: 0, colorId: 'white', hairStyle: 'bald', equipment: ['headband', 'sunglasses'] }, + ]; + const check = (drawing: string, slot: number) => { + assert.ok(drawing.includes(`class="eyewear eyewear-${slot === 0 ? 'glasses' : 'sunglasses'}"`)); + assert.equal((drawing.match(/class="eyewear /g) ?? []).length, 1); + }; + for (const hit of [0, 1] as const) { + for (const attack of ['blast', 'beam'] as const) { + const html = finisherArt(hit, attack, appearances); + const drawings = [...html.matchAll(//g)].map(match => match[0]); + const slots = attack === 'beam' ? [1 - hit, hit, 1 - hit, hit, hit] : [1 - hit, 0, 1]; + assert.equal(drawings.length, slots.length); + drawings.forEach((drawing, i) => check(drawing, slots[i])); + if (attack === 'blast') { + check(html.match(//)![0], hit); + } else { + check(html.match(//)![0], hit); + check(html.match(//)![0], hit); + } + } + } +}); + +test('explicit appearance starts barefoot without wristbands or headband in every pose', () => { + for (const characterId of [0, 1] as const) { + const html = fighterArt(1 - characterId, false, { characterId, colorId: 'white' }); + assert.ok(!html.includes('fill="#30383c"'), 'no shoe leather'); + assert.ok(!html.includes('fill="#864237"'), 'no headband or tails'); + assert.ok(html.includes('class="bare-feet"'), 'feet have dedicated anatomy'); + for (const pose of ['ready', 'charge', 'attack', 'guard', 'dodge', 'win', 'loss', 'draw', 'hit']) { + const drawing = section(html, `pose pose-${pose}`); + assert.ok(!drawing.includes('fill="var(--trim)"'), `${pose}: no wristband`); + } + assert.ok(!section(html, 'pose pose-draw').includes('m126 122h13'), 'no leftover wristband seams'); + assert.ok(html.includes('fill="#303238"'), 'belt remains'); + } +}); + +test('bald heads have a rounded scalp and independently equipped headbands for both characters', () => { + for (const characterId of [0, 1] as const) { + for (const closeup of [false, true]) { + const bare = fighterArt(1 - characterId, closeup, { characterId, colorId: 'white', hairStyle: 'bald' }); + assert.ok(bare.includes('class="bald-scalp"')); + assert.ok(!bare.includes('fill="var(--hair)"')); + assert.ok(!bare.includes('fill="#864237"')); + const band = fighterArt(1 - characterId, closeup, { characterId, colorId: 'white', hairStyle: 'bald', equipment: ['headband'] }); + assert.ok(band.includes('class="bald-scalp"')); + assert.ok(band.includes('m111 34 42-2-1 6-40 3Z'), 'headband'); + assert.ok(band.includes('m111 37-19 8-19-5'), 'tails'); + assert.ok(!band.includes('fill="var(--hair)"')); + } + } +}); + +test('equipment toggles are independent and keep the equipped martial shoes', () => { + for (const characterId of [0, 1] as const) { + for (const equipment of [['shoes'], ['wristbands'], ['headband']] as const) { + const html = fighterArt(0, false, { characterId, colorId: 'white', equipment: [...equipment] }); + assert.equal(html.includes('M60 275Q69 280 85 276'), equipment[0] === 'shoes'); + assert.equal(html.includes('M198 257Q203 263 213 261'), equipment[0] === 'shoes'); + assert.equal(section(html, 'pose pose-ready').includes('fill="var(--trim)"'), equipment[0] === 'wristbands'); + assert.equal(html.includes('fill="#864237"'), equipment[0] === 'headband'); + assert.ok(html.includes('fill="var(--hair)"'), 'default hair remains'); + } + } + assert.ok(fighterArt(0).includes('fill="#864237"')); + assert.ok(!fighterArt(1).includes('fill="#864237"')); + for (const slot of [0, 1]) assert.ok(fighterArt(slot).includes('M60 275Q69 280 85 276')); +}); + +test('blast and beam carry equipment and bald scalps through both slots and active death art', () => { + for (const hit of [0, 1] as const) { + for (const characterId of [0, 1] as const) { + const appearances: [FighterAppearance, FighterAppearance] = [ + { characterId: 0, colorId: 'orange', equipment: allEquipment }, + { characterId: 1, colorId: 'blue', equipment: allEquipment }, + ]; + appearances[hit] = { characterId, colorId: 'white', hairStyle: 'bald', equipment: [] }; + for (const attack of ['blast', 'beam'] as const) { + const html = finisherArt(hit, attack, appearances); + const drawings = [...html.matchAll(//g)].map(match => match[0]); + const victims = attack === 'beam' ? [drawings[1], drawings[3], drawings[4]] : [drawings[hit + 1]]; + for (const victim of victims) { + assert.ok(victim.includes('class="bald-scalp"')); + assert.ok(!victim.includes('fill="#30383c"')); + assert.ok(!victim.includes('fill="#864237"')); + } + assert.ok(drawings[0].includes('fill="#30383c"'), 'attacker retains equipment'); + if (attack === 'blast') { + const fallen = html.match(//)![0]; + assert.ok(fallen.includes('class="bald-scalp"')); + assert.ok(fallen.includes('class="bare-feet"')); + assert.ok(!fallen.includes('fill="#30383c"')); + assert.ok(!fallen.includes('m416 259 13 4-5 15-13-4Z')); + assert.ok(!fallen.includes('m317 230 13 10-7 7-13-10Z')); + assert.ok(!html.includes('fallen-pose-trace'), 'archived variant is inactive'); + } else { + const aftermath = html.slice(html.indexOf('class="cutscene-panel cutscene-fallen beam-aftermath"')); + assert.ok(!aftermath.includes('fill="var(--hair)"'), 'bald victims have no hair fragments'); + assert.ok(!aftermath.includes('fill="#30383c"'), 'barefoot victims have no shoe fragments'); + } + } + } + } +}); + +test('the archived Yamcha function remains byte-for-byte unchanged', () => { + const source = readFileSync(new URL('../src/client/fighter-art.ts', import.meta.url), 'utf8'); + const archived = source.match(/^export function yamchaFallenFighterArt[\s\S]*?^\}/m)![0]; + // SHA-256 of the exact function in the parent-provided pre-equipment backup. + assert.equal(createHash('sha256').update(archived).digest('hex'), 'b540712bc652b3380812299c7889c64ad4b0ae18a070d5dfa9f42e157ea5be2c'); +}); + +function domHarness(t: import('node:test').TestContext) { + class Element { + dataset: Record = {}; + textContent = ''; innerHTML = ''; outerHTML = ''; hidden = false; + children = new Map(); + style = { setProperty() {}, removeProperty() {} }; + classList = { toggle() {} }; + setAttribute() {} addEventListener() {} showModal() {} close() {} remove() {} + focus() { doc.activeElement = this; } + append(node: Element) { appended.push(node); } + querySelector(selector: string): Element { + if (!this.children.has(selector)) this.children.set(selector, new Element()); + return this.children.get(selector)!; + } + querySelectorAll(selector: string) { return selector === '[data-fighter]' ? actors : []; } + } + const actors = [new Element(), new Element()], appended: Element[] = []; + const container = new Element(); + const doc = { body: new Element(), activeElement: new Element(), createElement: () => new Element() }; + const globals = { document: doc, HTMLElement: Element, window: { + matchMedia: () => ({ matches: true, addEventListener() {}, removeEventListener() {} }), + } }; + for (const [key, value] of Object.entries(globals)) { + const previous = Object.getOwnPropertyDescriptor(globalThis, key); + Object.defineProperty(globalThis, key, { configurable: true, value }); + t.after(() => previous ? Object.defineProperty(globalThis, key, previous) : Reflect.deleteProperty(globalThis, key)); + } + const now = Date.now(); + const state: LocalMatchState = { + matchId: 'equipment-view', mode: 'friend', phase: 'selecting', version: 1, selfIndex: 0, + energy: [3, 3], turnId: 1, clockNow: now, startsAt: now, deadline: now + 10000, + ownAction: null, rematch: [false, false], opponentPresent: true, opponentLeft: false, + saveStatus: 'saved', aborted: false, + appearances: [{ characterId: 0, colorId: 'white' }, { characterId: 1, colorId: 'white' }], + }; + return { container, actors, appended, state }; +} + +test('match updates react to hair and equipment changes without rebuilding unchanged actors', t => { + const { container, actors, state } = domHarness(t); + const view = createMatchView(container as unknown as HTMLElement, async () => true, () => {}, () => {}); + try { + view.update(state); + const first = actors[0].querySelector('.character-direction'), second = actors[1].querySelector('.character-direction'); + second.innerHTML += ''; + state.appearances![0] = { characterId: 0, colorId: 'white', hairStyle: 'bald', equipment: ['shoes', 'headband'] }; + view.update({ ...state, version: 2 }); + assert.ok(first.innerHTML.includes('class="bald-scalp"')); + assert.ok(first.innerHTML.includes('M60 275Q69 280 85 276')); + assert.ok(first.innerHTML.includes('fill="#864237"')); + assert.ok(second.innerHTML.endsWith('')); + first.innerHTML += ''; + state.appearances![0].equipment = ['headband', 'shoes']; + view.update({ ...state, version: 3 }); + assert.ok(first.innerHTML.endsWith(''), 'equipment order is not an appearance change'); + } finally { view.destroy(); } +}); + +test('an open cutscene refreshes changed appearance art without restarting its stage or result control', t => { + const { appended, state } = domHarness(t); + state.matchId = 'equipment-cutscene'; state.phase = 'finished'; + state.reveal = { matchId: state.matchId, turnId: 1, actions: ['blast', 'charge'], energyBefore: [3, 3], energy: [2, 3], hitIndex: 1, winnerIndex: 0, revealedAt: state.clockNow - 10000, revealEndsAt: state.clockNow - 5000 }; + state.finished = { matchId: state.matchId, winnerIndex: 0, reason: 'hit', stats: emptyPlayerStats(), revealEndsAt: state.reveal.revealEndsAt, saved: true }; + const cutscene = createFinishCutscene(() => {}); + try { + cutscene.update(state); + const dialog = appended[0], button = dialog.querySelector('.cutscene-result'); + assert.equal(dialog.dataset.stage, 'complete'); + state.appearances![1] = { characterId: 1, colorId: 'white', hairStyle: 'bald', equipment: ['headband'] }; + cutscene.update({ ...state, version: 2 }); + const page = dialog.querySelector('.cutscene-page'); + assert.ok(page.outerHTML.includes('class="bald-scalp"')); + assert.ok(page.outerHTML.includes('fill="#864237"')); + assert.equal(dialog.dataset.stage, 'complete'); + assert.equal(dialog.querySelector('.cutscene-result'), button); + page.outerHTML += ''; + cutscene.update({ ...state, version: 3 }); + assert.ok(page.outerHTML.endsWith('')); + assert.equal(appended.length, 1); + } finally { cutscene.destroy(); } +}); diff --git a/tests/equipment-sync.test.ts b/tests/equipment-sync.test.ts new file mode 100644 index 0000000..78b54b8 --- /dev/null +++ b/tests/equipment-sync.test.ts @@ -0,0 +1,36 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { randomBytes, randomUUID } from 'node:crypto'; +import { MongoClient } from 'mongodb'; +import { createGameSessionStore } from '../src/server/game-session-store.js'; +import { createRoomStore } from '../src/server/rooms.js'; +import { normalizeAppearance } from '../src/shared/cosmetics.js'; + +test('bot and friend registrations snapshot all selected parts and keep them across session restoration', { skip: !process.env.TEST_MONGODB_URI }, async () => { + const client = await new MongoClient(process.env.TEST_MONGODB_URI!, { serverSelectionTimeoutMS: 5000 }).connect(); + const db = client.db(`dragon_ball_test_parts_${randomUUID().replaceAll('-', '')}`); + try { + const sessions = await createGameSessionStore(db), rooms = await createRoomStore(db); + const host = await sessions.create(randomBytes(32).toString('base64url'), 'equipment-host'); + const guest = await sessions.create(randomBytes(32).toString('base64url'), 'equipment-guest'); + const hostLook = normalizeAppearance({ characterId: 0, skinTone: 'light', colorId: 'blue', hairStyle: 'white', equipment: ['shoes', 'headband', 'glasses'] }); + const guestLook = normalizeAppearance({ characterId: 1, skinTone: 'tan', colorId: 'white', hairStyle: 'black', equipment: ['wristbands', 'sunglasses'] }); + await db.collection('player_profiles').insertMany([{ _id: 'equipment-host', appearance: hostLook }, { _id: 'equipment-guest', appearance: guestLook }]); + const bot = await sessions.register(host, { requestId: randomUUID(), matchId: randomUUID(), expectedPreviousSeq: 0, mode: 'bot' }); + assert.deepEqual(bot.appearances?.[0], hostLook); + assert.deepEqual((await sessions.get(host)).activeMatch?.appearances?.[0], hostLook); + await sessions.record(host, { ...bot, outcome: 'aborted', reason: 'connection_failed' }); + const room = await rooms.create(host.gameSessionId, randomUUID()); + const joined = await rooms.join(guest.gameSessionId, room.code); + assert.deepEqual(joined.appearances, [hostLook, guestLook]); + // Editing the lobby profile in a second tab must not change this already-paired match. + await db.collection('player_profiles').updateOne({ _id: 'equipment-host' }, { $set: { appearance: normalizeAppearance(undefined) } }); + const register = (session: typeof host, previous: number) => sessions.register(session, { + requestId: randomUUID(), matchId: joined.currentMatchId, expectedPreviousSeq: previous, mode: 'friend', roomId: joined.id, + }); + const [a, b] = await Promise.all([register(host, 1), register(guest, 0)]); + assert.deepEqual(a.appearances, [hostLook, guestLook]); + assert.deepEqual(b.appearances, a.appearances); + assert.deepEqual((await sessions.get(guest)).activeMatch?.appearances, a.appearances); + } finally { try { await db.dropDatabase(); } finally { await client.close(); } } +}); diff --git a/tests/game-api.test.ts b/tests/game-api.test.ts new file mode 100644 index 0000000..f0a6436 --- /dev/null +++ b/tests/game-api.test.ts @@ -0,0 +1,80 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { randomUUID } from 'node:crypto'; +import Fastify from 'fastify'; +import { MongoClient } from 'mongodb'; +import { createGameSessionStore } from '../src/server/game-session-store.js'; +import { registerGameApi } from '../src/server/game-api.js'; +import { createGameApi } from '../src/client/api.js'; +import { createStatsStore } from '../src/server/stats-store.js'; + +test('room creation reuses its request ID after response loss and a page reload', async () => { + const memory = new Map(), ids: string[] = []; + const storage = { getItem: (k: string) => memory.get(k) ?? null, setItem: (k: string, v: string) => { memory.set(k, v); }, removeItem: (k: string) => { memory.delete(k); } }; + const send: typeof fetch = async (input, init) => { + const data = String(input) === '/api/rooms' ? (() => { ids.push(JSON.parse(String(init?.body)).requestId); if (ids.length === 1) throw new Error('response lost'); return { id: 'room', status: 'waiting' }; })() : { gameSessionId: 'session', token: 'token' }; + return new Response(JSON.stringify({ ok: true, data })); + }; + const first = createGameApi(storage, send); await first.ensureSession(); await assert.rejects(first.createRoom(), /response lost/); + const restored = createGameApi(storage, send); await restored.ensureSession(); await restored.createRoom(); await restored.createRoom(); + assert.equal(ids[0], ids[1]); assert.notEqual(ids[1], ids[2]); +}); + +test('failed room cleanup survives refresh and expired or deleted rooms count as departed', async () => { + const memory = new Map(); let status = 503, leaves = 0; + const storage = { getItem: (k: string) => memory.get(k) ?? null, setItem: (k: string, v: string) => { memory.set(k, v); }, removeItem: (k: string) => { memory.delete(k); } }; + const send: typeof fetch = async input => { + if (String(input).endsWith('/leave')) { leaves++; return new Response(JSON.stringify({ ok: false, code: 'ROOM_EXPIRED', message: 'gone' }), { status }); } + return new Response(JSON.stringify({ ok: true, data: { gameSessionId: 's', token: 't'.repeat(43) } })); + }; + const first = createGameApi(storage, send); await first.ensureSession(); await assert.rejects(first.leaveRoom('r')); + const second = createGameApi(storage, send); await second.ensureSession(); status = 410; + assert.equal(await second.resumeRoomLeave(), true); assert.equal(await second.resumeRoomLeave(), false); + status = 404; await second.leaveRoom('deleted'); assert.equal(leaves, 3); +}); + +test('HTTP client restores its session, records once and surfaces conflicts', { skip: !process.env.TEST_MONGODB_URI }, async () => { + const client = await new MongoClient(process.env.TEST_MONGODB_URI!, { serverSelectionTimeoutMS: 5000 }).connect(); + const db = client.db(`dragon_ball_test_api_${randomUUID().replaceAll('-', '')}`); + const app = Fastify(); + try { + registerGameApi(app, await createGameSessionStore(db)); + const memory = new Map(); + const storage = { getItem: (k: string) => memory.get(k) ?? null, setItem: (k: string, v: string) => { memory.set(k, v); }, removeItem: (k: string) => { memory.delete(k); } }; + const localFetch: typeof fetch = async (input, init) => { + const headers: Record = {}; new Headers(init?.headers).forEach((value, key) => { headers[key] = value; }); + const response = await app.inject({ method: (init?.method ?? 'GET') as 'GET' | 'POST', url: String(input), headers, payload: init?.body as string | undefined }); + return new Response(response.body, { status: response.statusCode, headers: { 'content-type': 'application/json' } }); + }; + const api = createGameApi(storage, localFetch); + const session = await api.ensureSession(); + const restored = createGameApi(storage, localFetch); await restored.ensureSession(); + assert.equal(restored.session.gameSessionId, session.gameSessionId); + const match = await api.registerMatch({ requestId: randomUUID(), matchId: randomUUID(), mode: 'bot', expectedPreviousSeq: 0 }); + const win = { ...match, outcome: 'win' as const, reason: 'hit' as const }; + await restored.sync(); + assert.equal(restored.session.activeMatch?.matchId, match.matchId); + assert.equal((await api.record(win)).stats.bot.wins, 1); + assert.equal((await restored.sync()).activeMatch, undefined); + assert.equal((await restored.record(win)).status, 'duplicate'); + await assert.rejects(api.record({ ...win, outcome: 'loss' }), (e: any) => e.status === 409 && e.code === 'RESULT_CONFLICT'); + assert.equal((await app.inject({ method: 'GET', url: '/api/game-session' })).statusCode, 401); + assert.equal((await api.sync()).lastAppliedSeq, 1); + } finally { await app.close(); try { await db.dropDatabase(); } finally { await client.close(); } } +}); + +test('session creation retry preserves linked visitor credentials and rejects invalid keys before creating visitors', { skip: !process.env.TEST_MONGODB_URI }, async () => { + const visitors = await createStatsStore({ mongoUri: process.env.TEST_MONGODB_URI!, databaseName: `dragon_ball_test_api_${randomUUID().replaceAll('-', '')}` }); + const app = Fastify(); + try { + registerGameApi(app, await createGameSessionStore(visitors.db), visitors); + const createRequestId = 'a'.repeat(43); + const first = (await app.inject({ method: 'POST', url: '/api/game-sessions', payload: { createRequestId } })).json().data; + const retry = (await app.inject({ method: 'POST', url: '/api/game-sessions', payload: { createRequestId } })).json().data; + assert.equal(retry.gameSessionId, first.gameSessionId); + assert.equal(retry.guestToken, first.guestToken); + assert.equal((await visitors.findGuest(retry.guestToken))?.playerId, retry.playerId); + assert.equal((await app.inject({ method: 'POST', url: '/api/game-sessions', payload: { createRequestId: 'bad' } })).statusCode, 400); + assert.equal(await visitors.db.collection('players').countDocuments(), 1); + } finally { await app.close(); try { await visitors.db.dropDatabase(); } finally { await visitors.close(); } } +}); diff --git a/tests/game-records.test.ts b/tests/game-records.test.ts new file mode 100644 index 0000000..2b40805 --- /dev/null +++ b/tests/game-records.test.ts @@ -0,0 +1,61 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { randomBytes, randomUUID } from 'node:crypto'; +import { MongoClient } from 'mongodb'; +import { createGameSessionStore } from '../src/server/game-session-store.js'; + +const uri = process.env.TEST_MONGODB_URI; +test('Standalone session counters apply concurrent and late results exactly once', { skip: !uri }, async () => { + const client = await new MongoClient(uri!, { serverSelectionTimeoutMS: 5000 }).connect(); + const db = client.db(`dragon_ball_test_p2p_records_${randomUUID().replaceAll('-', '')}`); + try { + let now = Date.now(); + const store = await createGameSessionStore(db, () => now); + const creationKey = randomBytes(32).toString('base64url'); + const session = await store.create(creationKey); + assert.deepEqual(await store.create(creationKey), session); + const auth = { gameSessionId: session.gameSessionId, token: session.token }; + const registration = { requestId: randomUUID(), matchId: randomUUID(), mode: 'bot' as const, expectedPreviousSeq: 0 }; + const active = await store.register(auth, registration); + assert.deepEqual(await store.register(auth, registration), active); + const win = { ...active, outcome: 'win' as const, reason: 'hit' as const }; + const replies = await Promise.all(Array.from({ length: 20 }, () => store.record(auth, win))); + assert.equal(replies.filter(r => r.status === 'applied').length, 1); + assert.equal((await store.get(auth)).stats.bot.wins, 1); + assert.equal((await store.get(auth)).stats.bot.current, 1); + assert.equal((await store.record(auth, win)).status, 'duplicate'); // lost response retry + await assert.rejects(store.record(auth, { ...win, outcome: 'loss' }), /RESULT_CONFLICT/); + const second = await store.register(auth, { ...registration, requestId: randomUUID(), matchId: randomUUID(), expectedPreviousSeq: 1 }); + await store.record(auth, { ...second, outcome: 'loss', reason: 'hit' }); + assert.equal((await store.record(auth, win)).status, 'stale'); + assert.deepEqual((await store.get(auth)).stats.bot, { wins: 1, losses: 1, draws: 0, current: 0, best: 1 }); + const registrations = await Promise.allSettled([0, 1].map(() => store.register(auth, { ...registration, requestId: randomUUID(), matchId: randomUUID(), expectedPreviousSeq: 2 }))); + assert.equal(registrations.filter(r => r.status === 'fulfilled').length, 1); + const third = (await store.get(auth)).activeMatch!; + await store.record(auth, { ...third, outcome: 'aborted', reason: 'connection_failed' }); + assert.deepEqual((await store.get(auth)).stats.bot, { wins: 1, losses: 1, draws: 0, current: 0, best: 1 }); + await assert.rejects(store.record(auth, { ...third, seq: 9, matchId: randomUUID(), outcome: 'win', reason: 'hit' }), /NO_ACTIVE_MATCH/); + await assert.rejects(store.get({ ...auth, token: randomBytes(32).toString('base64url') }), /INVALID_SESSION/); + const fourth = await store.register(auth, { ...registration, requestId: randomUUID(), matchId: randomUUID(), expectedPreviousSeq: 3 }); + await store.record(auth, { ...fourth, outcome: 'draw', reason: 'double_timeout' }); + assert.equal((await store.get(auth)).stats.bot.draws, 1); + now += 91 * 86400000; + await assert.rejects(store.get(auth), /INVALID_SESSION/); + } finally { try { await db.dropDatabase(); } finally { await client.close(); } } +}); +test('friend registration requires membership, but expired room cannot prevent recording', { skip: !uri }, async () => { + const client = await new MongoClient(uri!, { serverSelectionTimeoutMS: 5000 }).connect(); + const db = client.db(`dragon_ball_test_p2p_member_${randomUUID().replaceAll('-', '')}`); + try { + let now = Date.now(); const store = await createGameSessionStore(db, () => now); + const session = await store.create(randomBytes(32).toString('base64url')); + const other = await store.create(randomBytes(32).toString('base64url')); + const request = { requestId: randomUUID(), matchId: randomUUID(), roomId: randomUUID(), mode: 'friend' as const, expectedPreviousSeq: 0 }; + await assert.rejects(store.register(session, request), /ROOM_NOT_FOUND/); + await db.collection('rooms').insertOne({ _id: request.roomId, hostGameSessionId: session.gameSessionId, guestGameSessionId: other.gameSessionId, status: 'paired', currentMatchId: request.matchId, expiresAt: new Date(now + 1000) }); + const active = await store.register(session, request); now += 2000; + await store.record(session, { ...active, outcome: 'win', reason: 'hit' }); + assert.equal((await store.get(session)).stats.friend.current, 1); + assert.equal((await store.get(session)).stats.bot.wins, 0); + } finally { try { await db.dropDatabase(); } finally { await client.close(); } } +}); diff --git a/tests/helpers.ts b/tests/helpers.ts new file mode 100644 index 0000000..fc77fb6 --- /dev/null +++ b/tests/helpers.ts @@ -0,0 +1,18 @@ +import type { Clock } from '../src/server/clock.js'; +export class ManualClock implements Clock { + time = Date.now(); id = 0; + timers = new Map void }>(); + now = () => this.time; + setTimeout = (callback: () => void, delay: number) => { const id = ++this.id; this.timers.set(id, { at: this.time + delay, callback }); return id; }; + clearTimeout = (id: any) => { this.timers.delete(id); }; + async advance(ms: number) { + const target = this.time + ms; + for (;;) { + const next = [...this.timers.entries()].filter(([, t]) => t.at <= target).sort((a, b) => a[1].at - b[1].at)[0]; + if (!next) break; + this.time = next[1].at; this.timers.delete(next[0]); next[1].callback(); + await new Promise(r => setTimeout(r, 20)); + } + this.time = target; await new Promise(r => setTimeout(r, 20)); + } +} diff --git a/tests/http-analytics.test.ts b/tests/http-analytics.test.ts new file mode 100644 index 0000000..67f8b8c --- /dev/null +++ b/tests/http-analytics.test.ts @@ -0,0 +1,70 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { randomUUID } from 'node:crypto'; +import Fastify from 'fastify'; +import { createStatsStore } from '../src/server/stats-store.js'; +import { createGameSessionStore } from '../src/server/game-session-store.js'; +import { createAnalyticsStore } from '../src/server/analytics-store.js'; +import { Analytics } from '../src/server/analytics.js'; +import { registerGameApi, registerAnalyticsApi } from '../src/server/game-api.js'; +import { ManualClock } from './helpers.js'; +import { getAnalyticsReport } from '../src/server/analytics-report.js'; + +test('HTTP analytics batches deduplicate, throttle activity writes and keep client game summaries separate', { skip: !process.env.TEST_MONGODB_URI }, async () => { + const stats = await createStatsStore({ mongoUri: process.env.TEST_MONGODB_URI!, databaseName: `dragon_ball_test_http_${randomUUID().replaceAll('-', '')}` }); + const app = Fastify(), clock = new ManualClock(); + const store = await createAnalyticsStore(stats.db); + let touches = 0; const originalTouch = store.touch; + store.touch = async (...args) => { touches++; return originalTouch(...args); }; + const analytics = new Analytics(store, clock, 'test'); + try { + const sessions = await createGameSessionStore(stats.db); + registerGameApi(app, sessions, stats); registerAnalyticsApi(app, sessions, analytics); + const session = (await app.inject({ method: 'POST', url: '/api/game-sessions', payload: { createRequestId: 'b'.repeat(43) } })).json().data; + const headers = { authorization: `Bearer ${session.token}`, 'x-game-session': session.gameSessionId }; + const post = (url: string, payload: any) => app.inject({ method: 'POST', url, headers, payload }); + const events = [{ eventId: 'page', eventType: 'page_view', screen: 'home', metadata: { device: 'mobile' } }, ...Array.from({ length: 19 }, (_, i) => ({ eventId: `screen${i}`, eventType: 'screen_view', screen: 'match' }))]; + assert.equal((await post('/api/analytics/events', { events })).statusCode, 200); + assert.equal((await post('/api/analytics/events', { events })).statusCode, 200); + assert.equal(await stats.db.collection('analytics_events').countDocuments(), 20); + assert.equal(touches, 1); + for (let i = 0; i < 10; i++) await post('/api/analytics/activity', { visible: true }); + assert.equal(touches, 1); + assert.equal(analytics.live().active, 1); + clock.time += 60000; + await post('/api/analytics/activity', { visible: true }); assert.equal(touches, 2); + for (let i = 0; i < 10; i++) await post('/api/analytics/activity', { visible: false }); + assert.equal(touches, 3); assert.equal(analytics.live().active, 0); + const summary = await post('/api/analytics/events', { events: [{ eventId: 'start', eventType: 'game_started', matchId: 'm', mode: 'friend' }] }); + assert.equal(summary.statusCode, 200); + const event = await stats.db.collection('analytics_events').findOne({ eventType: 'game_started' }); + assert.equal(event?.source, 'client'); assert.equal(event?.gameSessionId, session.gameSessionId); + assert.equal(await stats.db.collection('matches').countDocuments(), 0); + assert.equal((await post('/api/analytics/events', { events: Array(21).fill(events[0]) })).statusCode, 400); + assert.equal((await post('/api/analytics/events', { events: [{ eventId: 'fake', eventType: 'match_started' }] })).statusCode, 400); + assert.equal((await app.inject({ method: 'POST', url: '/api/analytics/activity', payload: { visible: true } })).statusCode, 401); + } finally { await app.close(); await analytics.close(); try { await stats.db.dropDatabase(); } finally { await stats.close(); } } +}); + +test('current client summaries remain available without legacy server metrics', { skip: !process.env.TEST_MONGODB_URI }, async () => { + const stats = await createStatsStore({ mongoUri: process.env.TEST_MONGODB_URI!, databaseName: `dragon_ball_test_source_${randomUUID().replaceAll('-', '')}` }); + const clock = new ManualClock(), store = await createAnalyticsStore(stats.db); + const analytics = new Analytics(store, clock); + try { + const guest = await stats.createGuest(), p = { playerId: guest.playerId, gameSessionId: 'game-session' }; + await analytics.clientEvent(p, { eventId: 'page', eventType: 'page_view', screen: 'home' }); + await analytics.clientEvent(p, { eventId: 'start', eventType: 'game_started', matchId: 'match', mode: 'friend' }); + await analytics.clientEvent(p, { eventId: 'finish', eventType: 'game_finished', matchId: 'match', mode: 'friend', outcome: 'win', reason: 'hit' }); + await analytics.clientEvent(p, { eventId: 'finish-after-refresh', eventType: 'game_finished', matchId: 'match', mode: 'friend', outcome: 'win', reason: 'hit' }); + clock.time++; + const report = await getAnalyticsReport(stats.db, { start: new Date(clock.now() - 86400000), end: new Date(clock.now()) }, new Date(clock.now())); + assert.equal(report.clientGameplay.source, 'client'); + assert.equal(report.clientGameplay.players, 1); assert.equal(report.clientGameplay.startedMatches, 1); + assert.equal(report.clientGameplay.resultReports, 1); assert.equal(report.clientGameplay.actions, null); + assert.equal(report.clientGameplay.turns, null); assert.equal(report.clientGameplay.serverVerifiedCompletion, null); + assert.ok(!('gameplay' in report)); + assert.ok(!('sessionConversion' in report.overview)); + assert.ok(!('visitOnly' in report.overview)); + assert.ok(!('conversion' in report.acquisition.source[0])); + } finally { await analytics.close(); try { await stats.db.dropDatabase(); } finally { await stats.close(); } } +}); diff --git a/tests/legacy-cleanup.test.ts b/tests/legacy-cleanup.test.ts new file mode 100644 index 0000000..c0c61ac --- /dev/null +++ b/tests/legacy-cleanup.test.ts @@ -0,0 +1,42 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { randomUUID } from 'node:crypto'; +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, resolve, sep } from 'node:path'; +import { BSON, MongoClient } from 'mongodb'; +import { cleanupLegacyData } from '../scripts/cleanup-legacy-data.js'; + +test('legacy cleanup backs up removed data, preserves current data and is repeatable', { skip: !process.env.TEST_MONGODB_URI }, async () => { + const client = await new MongoClient(process.env.TEST_MONGODB_URI!).connect(); + const db = client.db(`dragon_ball_test_cleanup_${randomUUID().replaceAll('-', '')}`); + const directory = await mkdtemp(join(tmpdir(), 'dragon-ball-cleanup-')); + try { + const date = new Date(); + await db.collection('matches').insertOne({ endedAt: date }); + await db.collection('players').insertOne({ firstMatchAt: date, firstVisitAt: date, guestTokenHash: 'keep-identity' }); + await db.collection('analytics_events').insertMany([{ eventType: 'match_started' }, { eventType: 'page_view' }, { eventType: 'game_finished', source: 'client' }]); + await db.collection('analytics_presence_samples').insertMany([{ connected: 2 }, { source: 'activity_reports', active: 1 }]); + for (const name of ['game_sessions', 'rooms', 'analytics_sessions', 'analytics_metadata']) await db.collection(name).insertOne({ keep: true }); + const before = await cleanupLegacyData(db); + assert.equal(before.applied, false); assert.deepEqual(before.counts, { matches: 1, events: 1, samples: 1, playerFields: 1 }); + assert.equal(await db.collection('matches').countDocuments(), 1); + const path = join(directory, 'backup.ejson'); + await cleanupLegacyData(db, path); + const backup = BSON.EJSON.parse(await readFile(path, 'utf8')); + assert.equal(+backup.matches[0].endedAt, +date); assert.equal(backup.players.length, 1); + assert.equal((await db.listCollections({ name: 'matches' }).toArray()).length, 0); + assert.equal(await db.collection('analytics_events').countDocuments(), 2); + assert.equal(await db.collection('analytics_presence_samples').countDocuments(), 1); + const player = await db.collection('players').findOne(); + assert.equal(player?.firstMatchAt, undefined); assert.equal(player?.guestTokenHash, 'keep-identity'); assert.equal(+player!.firstVisitAt, +date); + for (const name of ['game_sessions', 'rooms', 'analytics_sessions', 'analytics_metadata']) assert.equal(await db.collection(name).countDocuments({ keep: true }), 1); + const again = await cleanupLegacyData(db, join(directory, 'second.ejson')); + assert.deepEqual(again.counts, { matches: 0, events: 0, samples: 0, playerFields: 0 }); + await assert.rejects(cleanupLegacyData(db, path), { code: 'EEXIST' }); + } finally { + await db.dropDatabase(); await client.close(); + assert.ok(resolve(directory).startsWith(resolve(tmpdir()) + sep + 'dragon-ball-cleanup-')); + await rm(directory, { recursive: true }); + } +}); diff --git a/tests/lobby-profile.test.ts b/tests/lobby-profile.test.ts new file mode 100644 index 0000000..8bb5f09 --- /dev/null +++ b/tests/lobby-profile.test.ts @@ -0,0 +1,135 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import type { GameApi } from '../src/client/api.js'; +import { createLobbyFeatures } from '../src/client/lobby-features.js'; +import { normalizeAppearance, type FighterAppearance } from '../src/shared/cosmetics.js'; +import type { PlayerProfile } from '../src/shared/ranked-types.js'; + +// A selector/event surface, not an HTML parser or rendering implementation. +// The test invokes the production selection/equip handlers and observes the +// appearance callback used by main.ts to display the equipped character. +function documentSurface() { + const ids = new Map(); + class Element { + nodes = new Map(); + listeners = new Map void>(); + classList = { remove() {} }; + disabled = false; textContent = ''; innerHTML = ''; value = ''; + onclick?: () => void | Promise; + set id(id: string) { ids.set(id, this); } + querySelector(selector: string): Element { + if (!this.nodes.has(selector)) this.nodes.set(selector, new Element()); + return this.nodes.get(selector)!; + } + addEventListener(name: string, listener: (event: any) => void) { this.listeners.set(name, listener); } + prepend() {} after() {} append() {} showModal() {} close() {} + } + const document = { + body: new Element(), createElement: () => new Element(), querySelector: () => new Element(), + getElementById(id: string) { + if (!ids.has(id)) ids.set(id, new Element()); + return ids.get(id)!; + }, + }; + return document; +} + +test('a profile read started before equip cannot undo the saved appearance', async t => { + const document = documentSurface(), original = Object.getOwnPropertyDescriptor(globalThis, 'document'); + Object.defineProperty(globalThis, 'document', { configurable: true, value: document }); + t.after(() => { + if (original) Object.defineProperty(globalThis, 'document', original); + else Reflect.deleteProperty(globalThis, 'document'); + }); + const profile = (skinTone: 'light' | 'tan'): PlayerProfile => ({ + playerId: 'player-a', displayName: 'Alpha', appearance: normalizeAppearance({ characterId: 0, skinTone, colorId: 'white' }), + stats: { wins: 0, losses: 0, draws: 0, current: 0, best: 0 }, unlockedColors: ['white'], unlockedEquipment: [], + }); + let finishOldRead!: (value: PlayerProfile) => void; + const oldRead = new Promise(resolve => { finishOldRead = resolve; }); + let reads = 0, savedAppearance: FighterAppearance | undefined; + const appearances: FighterAppearance[] = []; + let savedDisplayed!: () => void; + const displayed = new Promise(resolve => { savedDisplayed = resolve; }); + const api = { async request(path: string, method?: string, body?: { appearance: FighterAppearance }) { + assert.equal(path, '/api/profile'); + if (method === 'POST') { + savedAppearance = body!.appearance; + return { ...profile('light'), appearance: normalizeAppearance(savedAppearance) }; + } + return ++reads === 1 ? profile('light') : oldRead; + } } as unknown as GameApi; + const lobby = createLobbyFeatures({ api, onQueue() {}, onMatch() {}, onHome() {}, onAvailabilityChange() {}, + onError(error) { throw error; }, + onAppearance(appearance) { + appearances.push(appearance); + if (appearance.skinTone === 'tan') savedDisplayed(); + }, + }); + await lobby.initialize(false); + const opening = document.getElementById('appearance-open').onclick!(); + const dialog = document.getElementById('appearance-dialog'); + dialog.querySelector('.skin-options').listeners.get('click')!({ + target: { closest: () => ({ dataset: { skin: 'tan' } }) }, + }); + const equip = dialog.querySelector('.equip'); + assert.equal(equip.disabled, false); + equip.onclick!(); + await displayed; + assert.deepEqual(savedAppearance, normalizeAppearance({ characterId: 0, skinTone: 'tan', colorId: 'white' })); + finishOldRead(profile('light')); + await opening; + assert.deepEqual(appearances.at(-1), savedAppearance, 'the late GET must not revert the displayed equipment'); + assert.equal(equip.disabled, true, 'the saved preview must still be equipped'); +}); + +for (const editedDuringLoad of [false, true]) { + test(editedDuringLoad + ? 'refresh preserves hair edits made while the profile is loading' + : 'refresh replaces an untouched preview with equipment saved in another tab', async t => { + const document = documentSurface(), original = Object.getOwnPropertyDescriptor(globalThis, 'document'); + Object.defineProperty(globalThis, 'document', { configurable: true, value: document }); + t.after(() => { + if (original) Object.defineProperty(globalThis, 'document', original); + else Reflect.deleteProperty(globalThis, 'document'); + }); + const cached = normalizeAppearance({ characterId: 0, colorId: 'white' }); + const remote = normalizeAppearance({ characterId: 1, colorId: 'white', equipment: ['shoes'] }); + const profile = (appearance: FighterAppearance): PlayerProfile => ({ + playerId: 'player-a', displayName: 'Alpha', appearance, + stats: { wins: 5, losses: 0, draws: 0, current: 5, best: 5 }, + unlockedColors: ['white'], unlockedEquipment: ['shoes', 'wristbands'], + }); + let finishRead!: (profile: PlayerProfile) => void; + const pendingRead = new Promise(resolve => { finishRead = resolve; }); + let reads = 0, savedAppearance: FighterAppearance | undefined; + let savedDisplayed!: () => void; + const displayed = new Promise(resolve => { savedDisplayed = resolve; }); + const api = { async request(path: string, method?: string, body?: { appearance: FighterAppearance }) { + assert.equal(path, '/api/profile'); + if (method === 'POST') { savedAppearance = body!.appearance; return profile(savedAppearance); } + return ++reads === 1 ? profile(cached) : pendingRead; + } } as unknown as GameApi; + const lobby = createLobbyFeatures({ api, onQueue() {}, onMatch() {}, onHome() {}, onAvailabilityChange() {}, + onError(error) { throw error; }, onAppearance() { if (savedAppearance) savedDisplayed(); }, + }); + await lobby.initialize(false); + const opening = document.getElementById('appearance-open').onclick!(); + const dialog = document.getElementById('appearance-dialog'); + const chooseBald = () => dialog.querySelector('.hair-options').listeners.get('click')!({ + target: { closest: () => ({ dataset: { hair: 'bald' } }) }, + }); + if (editedDuringLoad) chooseBald(); + finishRead(profile(remote)); + await opening; + const equip = dialog.querySelector('.equip'); + assert.equal(equip.disabled, !editedDuringLoad, 'an untouched refreshed preview is already equipped'); + if (!editedDuringLoad) chooseBald(); + assert.equal(equip.disabled, false); + equip.onclick!(); + await displayed; + assert.deepEqual(savedAppearance, editedDuringLoad + ? normalizeAppearance({ characterId: 0, colorId: 'white', hairStyle: 'bald', equipment: [] }) + : normalizeAppearance({ characterId: 1, colorId: 'white', hairStyle: 'bald', equipment: ['shoes'] })); + }); +} diff --git a/tests/main-recovery.test.ts b/tests/main-recovery.test.ts new file mode 100644 index 0000000..66a41e0 --- /dev/null +++ b/tests/main-recovery.test.ts @@ -0,0 +1,63 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import vm from 'node:vm'; +import ts from 'typescript'; +import { GAME_CHECKPOINT_KEY, GAME_ROOM_KEY } from '../constants/recording.js'; +import { HOME_TELEPORT_APPEARANCE } from '../constants/home.js'; +import { createGameApi, ApiClientError } from '../src/client/api.js'; +import { emptyPlayerStats } from '../src/shared/p2p-types.js'; + +const source = readFileSync('src/client/main.ts', 'utf8').replace(/^import .*;\r?\n/gm, ''); +const compiled = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.None } }).outputText; +const flush = async () => { for (let i = 0; i < 8; i++) await new Promise(r => setImmediate(r)); }; +async function setup(kind: 'expired' | 'orphan' | 'rematch-wait') { + const mem = new Map(), calls: string[] = [], polls = new Map void>(), launched: string[] = []; + const active = { matchId: 'm', seq: 1, mode: 'friend', roomId: 'r' }; + const session: any = { gameSessionId: 's', token: 't'.repeat(43), stats: emptyPlayerStats(), lastAppliedSeq: kind === 'orphan' ? 0 : 1, ...(kind === 'orphan' ? { activeMatch: active } : {}) }; + const room: any = { id: 'r', code: 'ABCDEF', currentMatchId: 'm', round: 1, selfIndex: 0, status: 'paired', count: 2, expiresAt: Date.now() + 60000, rematch: [kind === 'rematch-wait', false] }; + let open = true, pollId = 0; + mem.set(GAME_ROOM_KEY, JSON.stringify({ gameSessionId: 's', room })); + if (kind !== 'orphan') mem.set(GAME_CHECKPOINT_KEY, JSON.stringify({ gameSessionId: 's', activeMatch: active, checkpoint: { state: { + matchId: 'm', mode: 'friend', phase: 'finished', selfIndex: 0, version: 1, finished: { saved: true, stats: session.stats, reason: 'hit' }, rematch: room.rematch, saveStatus: 'saved', + } } })); + const send: typeof fetch = async (input, init) => { + const path = String(input); calls.push(path); let data: any = session; + if (path === '/api/rooms/r' || path.endsWith('/leave')) { + if (kind === 'expired') return new Response(JSON.stringify({ ok: false, code: 'ROOM_EXPIRED', message: 'expired' }), { status: 410 }); + if (path.endsWith('/leave')) open = false; + data = room; + } else if (path === '/api/game-session/results') { delete session.activeMatch; session.lastAppliedSeq = 1; data = { status: 'applied', stats: session.stats }; } + else if (path === '/api/game-session/matches') { const body = JSON.parse(String(init?.body)); data = { ...body, seq: 2 }; } + else if (path.startsWith('/api/rtc-config')) data = { iceServers: [] }; + return new Response(JSON.stringify({ ok: true, data })); + }; + const storage = { getItem: (k: string) => mem.get(k) ?? null, setItem: (k: string, v: string) => { mem.set(k,v); }, removeItem: (k: string) => { mem.delete(k); } }; + const nodes = new Map(); + const el = (id: string) => { if (!nodes.has(id)) nodes.set(id, { classList: { toggle() {}, add() {}, remove() {} }, addEventListener() {}, focus() {} }); return nodes.get(id); }; + const ctx: any = { Map, Date, Promise, JSON, URL, URLSearchParams, Number, crypto, ApiClientError, GAME_CHECKPOINT_KEY, GAME_ROOM_KEY, GUEST_TOKEN_KEY: 'guest', ROOM_POLL_MS: 1000, ACTIONS: {}, ACTION_IDS: [], + document: { getElementById: el, body: { dataset: {} }, querySelectorAll: () => [] }, location: { origin: 'http://local', search: '' }, sessionStorage: storage, localStorage: { getItem: () => null, setItem() {} }, navigator: {}, fighterArt: () => '', + GAME_NAME: '쎄쎄쎄:기공대전', HOME_TELEPORT_APPEARANCE, createGameApi: () => createGameApi(storage, send), + createClientAnalytics: () => ({ ready() {}, screen() {}, gameFinished() {}, gameStarted() {}, error() {}, click() {} }), + createResultReporter: (api: any) => ({ destroy() {}, resumePending: async () => {}, submit: api.record }), + createMatchView: () => ({ update() {}, destroy() {} }), createPeer: () => ({}), + createLobbyFeatures: () => ({ initialize: async () => false, setReady() {}, active: false }), + createMatchController: (config: any) => { launched.push(config.activeMatch.matchId); return { subscribe() {}, start: async () => {}, view: () => ({ matchId: config.activeMatch.matchId }), destroy() {} }; }, + setTimeout(fn: () => void) { polls.set(++pollId, fn); return pollId; }, clearTimeout(id: number) { polls.delete(id); }, + }; + vm.createContext(ctx); vm.runInContext(compiled + '\nglobalThis.review={leave,boot,inspect:()=>({ready,currentScreen,currentRoom})};', ctx); await flush(); + return { ctx, calls, polls, room, mem, launched, open: () => open }; +} +test('already-expired room does not trap a restored result on exit', async () => { + const h = await setup('expired'); await h.ctx.review.leave(); + assert.equal(h.ctx.review.inspect().currentScreen, 'home'); assert.equal(h.mem.has(GAME_ROOM_KEY), false); +}); +test('orphan registration recovery closes the persisted room membership before forgetting it', async () => { + const h = await setup('orphan'); assert.equal(h.open(), false); + assert.ok(h.calls.includes('/api/rooms/r/leave')); assert.equal(h.ctx.review.inspect().currentScreen, 'home'); +}); +test('refresh restores HTTP rematch consent polling and starts an advanced round', async () => { + const h = await setup('rematch-wait'); assert.ok(h.calls.includes('/api/rooms/r')); assert.equal(h.polls.size, 1); + h.room.round = 2; h.room.currentMatchId = 'new-match'; h.room.rematch = [false, false]; + [...h.polls.values()][0](); await flush(); assert.deepEqual(h.launched, ['new-match']); +}); diff --git a/tests/match-controller.test.ts b/tests/match-controller.test.ts new file mode 100644 index 0000000..235517f --- /dev/null +++ b/tests/match-controller.test.ts @@ -0,0 +1,41 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createMatchController } from '../src/client/match-controller.js'; +import { emptyPlayerStats, type ResultSubmission } from '../src/shared/p2p-types.js'; +import { ManualClock } from './helpers.js'; + +function harness() { + const clock = new ManualClock(); clock.time = 1000; + const recorded: ResultSubmission[] = [], memory = new Map(); + const stats = emptyPlayerStats(); + const controller = createMatchController({ activeMatch: { matchId: 'local-bot', seq: 1, mode: 'bot' }, gameSessionId: 'session', selfIndex: 0, clock, random: () => 0, + storage: { getItem: k => memory.get(k) ?? null, setItem: (k, v) => { memory.set(k, v); }, removeItem: k => { memory.delete(k); } }, + reporter: { submit: async data => { recorded.push(data); return { status: 'applied', stats }; } }, + }); + return { controller, clock, recorded, memory }; +} +test('local bot runs countdown, turns, result and persistence without Socket.IO', async () => { + const h = harness(); const seen: string[] = []; h.controller.subscribe(s => seen.push(s.phase)); + try { + await h.controller.start(); await h.clock.advance(3000); + assert.equal(h.controller.view().phase, 'selecting'); + assert.equal(await h.controller.submitAction('blast'), false); + assert.equal(await h.controller.submitAction('charge'), true); + await h.clock.advance(4000); assert.deepEqual(h.controller.view().energy, [1, 1]); + await h.clock.advance(1200); assert.equal(await h.controller.submitAction('blast'), true); + await h.clock.advance(4000); + assert.equal(h.controller.view().finished?.winnerIndex, 0); + assert.equal(h.controller.view().saveStatus, 'saved'); + assert.equal(h.recorded.length, 1); assert.equal(h.recorded[0].outcome, 'win'); + assert.ok(seen.includes('countdown')); assert.ok(seen.includes('revealing')); + } finally { h.controller.destroy(); } + assert.equal(h.clock.timers.size, 0); +}); +test('timeout records a loss once and local leave records forfeit', async () => { + const h = harness(); + try { await h.controller.start(); await h.clock.advance(7000); assert.equal(h.recorded[0].outcome, 'loss'); assert.equal(h.recorded[0].reason, 'timeout'); } + finally { h.controller.destroy(); } + const other = harness(); + try { await other.controller.start(); await other.controller.leave(); assert.equal(other.recorded[0].reason, 'forfeit'); assert.equal(other.recorded[0].outcome, 'loss'); } + finally { other.controller.destroy(); } +}); diff --git a/tests/match-engine.test.ts b/tests/match-engine.test.ts new file mode 100644 index 0000000..68a51ff --- /dev/null +++ b/tests/match-engine.test.ts @@ -0,0 +1,88 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createMatchEngine } from '../src/shared/match-engine.js'; + +function setup(mode: 'bot' | 'friend' = 'friend') { + let now = 1000; + const engine = createMatchEngine({ matchId: 'test-match', mode, selfIndex: 0, clock: { now: () => now }, random: () => 0 }); + return { engine, at: (value: number) => { now = value; } }; +} +test('countdown and turn deadlines gate actions without a server', () => { + const { engine, at } = setup(); + engine.start(1000); + assert.equal(engine.view().phase, 'countdown'); + assert.equal(engine.lockAction('charge'), false); + at(3999); assert.equal(engine.beginTurn(), false); + at(4000); assert.equal(engine.beginTurn(), true); + assert.equal(engine.view().deadline, 8000); + assert.equal(engine.lockAction('blast'), false); + assert.equal(engine.lockAction('charge'), true); + assert.equal(engine.lockAction('guard'), false); + at(8000); assert.equal(engine.lockAction('charge'), false); + engine.resolve(['charge', 'charge']); + assert.deepEqual(engine.view().energy, [1, 1]); + assert.equal(engine.view().phase, 'revealing'); + at(9199); assert.equal(engine.beginTurn(), false); + at(9200); assert.equal(engine.beginTurn(), true); + assert.equal(engine.view().turnId, 2); +}); +test('a turn resolves once and a hit ends the game', () => { + const { engine, at } = setup(); engine.start(1000); at(4000); engine.beginTurn(); at(8000); + engine.resolve(['charge', 'charge']); + assert.throws(() => engine.resolve(['charge', 'charge']), /NOT_SELECTING/); + at(9200); engine.beginTurn(); at(13200); engine.resolve(['blast', 'charge']); + assert.equal(engine.view().finished?.winnerIndex, 0); + assert.equal(engine.view().finished?.reason, 'hit'); + assert.equal(engine.lockAction('charge'), false); + assert.equal(engine.beginTurn(), false); +}); +test('only verified missing choices count as timeouts; technical abort leaves outcome separate', () => { + const { engine, at } = setup(); engine.start(1000); at(4000); engine.beginTurn(); at(8000); + engine.resolve([null, 'charge']); + assert.equal(engine.view().finished?.winnerIndex, 1); + assert.equal(engine.view().finished?.reason, 'timeout'); + const other = setup(); other.engine.start(1000); other.at(4000); other.engine.beginTurn(); other.at(8000); + other.engine.resolve([null, null]); + assert.equal(other.engine.view().finished?.reason, 'double_timeout'); + const aborted = setup(); aborted.engine.abort('connection_failed'); + assert.equal(aborted.engine.view().aborted, true); + assert.equal(aborted.engine.view().phase, 'finished'); +}); +test('checkpoints are copies and restore cannot overwrite match identity', () => { + const { engine, at } = setup(); engine.start(1000); at(4000); engine.beginTurn(); engine.lockAction('charge'); + const snapshot = engine.checkpoint(); snapshot.state.energy[0] = 99; + assert.equal(engine.view().energy[0], 0); + const clean = engine.checkpoint(); + const resumed = setup(); resumed.at(4000); resumed.engine.restore(clean); + assert.equal(resumed.engine.view().ownAction, 'charge'); + assert.throws(() => resumed.engine.restore({ ...clean, matchId: 'other' }), /MATCH_MISMATCH/); +}); +test('bot commits from public energy before human submission', () => { + const { engine, at } = setup('bot'); engine.start(1000); at(4000); engine.beginTurn(); + assert.equal(engine.botAction(), 'charge'); + engine.lockAction('guard'); assert.equal(engine.botAction(), 'charge'); + at(8000); engine.resolve([engine.view().ownAction, engine.botAction()]); + assert.deepEqual(engine.view().energy, [0, 1]); +}); +test('confirmed reveal timing and recovery keep energy while discarding an unconfirmed choice', () => { + const { engine, at } = setup(); engine.start(1000); at(4000); engine.beginTurn(); at(8000); + engine.resolve(['charge', 'charge']); engine.present(8500); + assert.equal(engine.view().reveal?.revealEndsAt, 9700); + const confirmed = engine.checkpoint(); at(9700); engine.beginTurn(); engine.lockAction('blast'); + at(11000); engine.resumeTurn(confirmed, 11000); + assert.deepEqual(engine.view().energy, [1, 1]); assert.equal(engine.view().turnId, 2); + assert.equal(engine.view().ownAction, null); assert.equal(engine.view().deadline, 15000); +}); + +test('rollback and recovery keep published revisions increasing so the view accepts restored states', () => { + const { engine, at } = setup(); engine.start(); const confirmed = engine.checkpoint(); + at(4000); engine.beginTurn(); engine.lockAction('charge'); engine.presence(false); + const before = engine.view().version; + engine.restore(confirmed); engine.abort('connection_failed'); + assert.ok(engine.view().version > before); + const other = setup(); other.engine.start(); const start = other.engine.checkpoint(); + other.at(4000); other.engine.beginTurn(); other.engine.lockAction('charge'); other.engine.presence(false); + const previous = other.engine.view().version; + other.engine.resumeTurn(start, 5000); + assert.ok(other.engine.view().version > previous); +}); diff --git a/tests/p2p-recovery.test.ts b/tests/p2p-recovery.test.ts new file mode 100644 index 0000000..7f72ebe --- /dev/null +++ b/tests/p2p-recovery.test.ts @@ -0,0 +1,132 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createMatchController } from '../src/client/match-controller.js'; +import { emptyPlayerStats, type PeerMessage, type ResultSubmission } from '../src/shared/p2p-types.js'; +import { ManualClock } from './helpers.js'; +import type { PeerState } from '../src/client/peer.js'; + +test('failed rematch request remains retryable', async () => { + let attempts = 0; + const h = setup(async () => { if (++attempts === 1) throw new Error('offline'); }); const [a, b] = h.controllers; + try { + await Promise.all(h.controllers.map(c => c.start())); await a.leave(); await h.clock.advance(1); + assert.equal(a.view().finished?.reason, 'forfeit'); assert.equal(b.view().finished?.winnerIndex, 1); + await assert.rejects(a.rematch(), /offline/); assert.equal(a.view().rematch[0], false); + await a.rematch(); assert.equal(attempts, 2); + } finally { h.close(); } +}); + +class Wire { + epoch = 0; connected = false; remote?: Wire; drop = false; + intercept?: (message: PeerMessage) => boolean; + messages = new Set<(m: PeerMessage) => void>(); states = new Set<(s: PeerState) => void>(); + async connect() { this.connected = true; } + send(message: PeerMessage) { + if (!this.connected) throw new Error('DISCONNECTED'); + if (this.intercept?.(message)) return; + if (!this.drop) queueMicrotask(() => { if (this.remote?.connected) for (const h of this.remote.messages) h(structuredClone(message)); }); + } + onMessage(h: (m: PeerMessage) => void) { this.messages.add(h); return () => { this.messages.delete(h); }; } + onState(h: (s: PeerState) => void) { this.states.add(h); return () => { this.states.delete(h); }; } + async restart(epoch = this.epoch + 1) { this.epoch = epoch; this.connected = true; } + close() { this.connected = false; } + disconnect() { this.connected = false; for (const h of this.states) h('disconnected'); } +} +function setup(onRematch?: () => Promise, guestClockOffset = 0) { + const clock = new ManualClock(); clock.time = 1000; + const a = new Wire(), b = new Wire(); a.remote = b; b.remote = a; + const records: ResultSubmission[][] = [[], []]; + const storages: any[] = []; + const controllers = [a, b].map((peer, slot) => { + const memory = new Map(); + const storage = { getItem: (k: string) => memory.get(k) ?? null, setItem: (k: string, v: string) => { memory.set(k, v); }, removeItem: (k: string) => { memory.delete(k); } }; storages.push(storage); + const localClock = slot === 1 ? { now: () => clock.now() + guestClockOffset, setTimeout: clock.setTimeout, clearTimeout: clock.clearTimeout } : clock; + return createMatchController({ activeMatch: { matchId: 'friend-match', seq: 1, mode: 'friend', roomId: 'r' }, gameSessionId: `session-${slot}`, selfIndex: slot as 0 | 1, clock: localClock, peer, onRematch, + reporter: { submit: async data => { records[slot].push(data); return { status: 'applied', stats: emptyPlayerStats() }; } }, + storage, + }); + }); + return { clock, a, b, controllers, records, storages, close: () => controllers.forEach(c => c.destroy()) }; +} +test('two peers agree turns and record complementary results without server turn events', async () => { + const h = setup(); const [a, b] = h.controllers; + try { + await Promise.all(h.controllers.map(c => c.start())); await h.clock.advance(3000); + assert.equal(a.view().phase, 'selecting'); assert.equal(b.view().phase, 'selecting'); + await a.submitAction('charge'); await b.submitAction('charge'); await h.clock.advance(4000); + assert.deepEqual(a.view().energy, [1, 1]); assert.deepEqual(b.view().energy, [1, 1]); + await h.clock.advance(1200); await a.submitAction('blast'); await b.submitAction('charge'); await h.clock.advance(4000); + assert.equal(h.records[0][0]?.outcome, 'win'); assert.equal(h.records[1][0]?.outcome, 'loss'); + assert.equal(h.records[0].length, 1); assert.equal(h.records[1].length, 1); + } finally { h.close(); } +}); + +test('loss of final presentation after digest acknowledgements preserves both agreed results', async () => { + const h = setup(); const [a, b] = h.controllers; + try { + await Promise.all(h.controllers.map(c => c.start())); await h.clock.advance(3000); + await a.submitAction('charge'); await b.submitAction('charge'); await h.clock.advance(5200); + await a.submitAction('blast'); await b.submitAction('charge'); + h.a.intercept = m => { if (m.type !== 'turn:present') return false; queueMicrotask(() => { h.a.drop = h.b.drop = true; h.a.disconnect(); h.b.disconnect(); }); return true; }; + await h.clock.advance(14000); + assert.deepEqual(h.records.map(r => r.map(x => x.outcome)), [['win'], ['loss']]); + } finally { h.close(); } +}); + +test('crossed explicit leave requests produce one draw per player instead of two wins', async () => { + const h = setup(); + try { + await Promise.all(h.controllers.map(c => c.start())); + await Promise.all(h.controllers.map(c => c.leave())); await h.clock.advance(1); + assert.deepEqual(h.records.map(r => r.map(x => x.outcome)), [['draw'], ['draw']]); + } finally { h.close(); } +}); +test('recovery starts a fresh epoch and ignores messages from the previous connection', async () => { + const h = setup(); const [a, b] = h.controllers; + try { + await Promise.all(h.controllers.map(c => c.start())); await h.clock.advance(3000); await a.submitAction('charge'); + h.a.disconnect(); h.b.disconnect(); await h.clock.advance(100); + assert.equal(h.a.epoch, 1); assert.equal(h.b.epoch, 1); + assert.equal(a.view().ownAction, null); assert.equal(b.view().ownAction, null); + assert.equal(h.records[0].length, 0); + h.a.send({ protocolVersion: 1, rulesVersion: '1', matchId: 'friend-match', connectionEpoch: 0, turnId: 1, type: 'turn:commit', digest: '0'.repeat(64), slot: 0 }); + await h.clock.advance(1); assert.equal(b.view().phase, 'selecting'); + } finally { h.close(); } +}); + +test('peers whose wall clocks differ by five seconds agree on deadline and timeout result', async () => { + const h = setup(undefined, 5000); const [a, b] = h.controllers; + try { + await Promise.all(h.controllers.map(c => c.start())); await h.clock.advance(3000); + assert.equal(a.view().deadline, b.view().deadline); assert.equal(a.view().clockNow, b.view().clockNow); + await a.submitAction('charge'); await h.clock.advance(4000); + assert.equal(h.records[0][0]?.outcome, 'win'); assert.equal(h.records[1][0]?.outcome, 'loss'); + assert.equal(a.view().finished?.reason, 'timeout'); assert.equal(b.view().finished?.reason, 'timeout'); + } finally { h.close(); } +}); +test('unrecoverable transport interruption aborts rather than inventing two wins', async () => { + const h = setup(); + try { + await Promise.all(h.controllers.map(c => c.start())); await h.clock.advance(3000); + h.a.drop = h.b.drop = true; h.a.disconnect(); h.b.disconnect(); + await new Promise(r => setImmediate(r)); // drain already-delivered messages before advancing the recovery clock + await h.clock.advance(10000); + assert.equal(h.records[0][0]?.outcome, 'aborted'); assert.equal(h.records[1][0]?.outcome, 'aborted'); + } finally { h.close(); } +}); + +test('refresh of a mutually confirmed final result saves without reconnecting to the opponent', async () => { + const h = setup(); + try { + await Promise.all(h.controllers.map(c => c.start())); await h.clock.advance(7000); + const { GAME_CHECKPOINT_KEY } = await import('../constants/recording.js'); + const saved = JSON.parse(h.storages[0].getItem(GAME_CHECKPOINT_KEY)); + saved.checkpoint.state.finished.saved = false; saved.checkpoint.state.saveStatus = 'pending'; + h.storages[0].setItem(GAME_CHECKPOINT_KEY, JSON.stringify(saved)); + h.close(); let recorded = 0; + const restored = createMatchController({ activeMatch: saved.activeMatch, gameSessionId: 'session-0', selfIndex: 0, clock: h.clock, storage: h.storages[0], + reporter: { submit: async data => { recorded++; assert.equal(data.outcome, 'draw'); return { status: 'duplicate', stats: emptyPlayerStats() }; } } }); + try { await restored.start(); assert.equal(recorded, 1); assert.equal(restored.view().saveStatus, 'saved'); } + finally { restored.destroy(); } + } finally { h.close(); } +}); diff --git a/tests/peer-browser-monitor.js b/tests/peer-browser-monitor.js new file mode 100644 index 0000000..28d8b0f --- /dev/null +++ b/tests/peer-browser-monitor.js @@ -0,0 +1,46 @@ +// Diagnostic harness for real-browser testing; excluded from production bundles. +(() => { + const NativePeer = window.RTCPeerConnection, NativeSocket = window.WebSocket; + const trace = []; let sockets = 0, nextPeer = 0; + document.addEventListener('DOMContentLoaded', () => { + const controls = document.createElement('aside'); + controls.style.cssText = 'position:fixed;right:0;top:0;z-index:100;background:white;color:black;padding:4px'; + controls.innerHTML = ''; + document.body.append(controls); + setInterval(() => { + const mode = controls.querySelector('select').value; + if (mode === 'none') return; + const blast = document.querySelector('button[data-action="blast"]'); + const action = mode === 'blast' ? blast && !blast.disabled ? 'blast' : 'charge' : mode; + const button = document.querySelector('button[data-action="' + action + '"]'); + if (button && !button.disabled && button.getClientRects().length) button.click(); + }, 100); + }); + function report(event, details = {}) { + trace.push({ event, ...details }); if (trace.length > 80) trace.shift(); + console.info('RTC_TEST ' + JSON.stringify({ sockets, ...trace.at(-1) })); + let output = document.getElementById('rtc-test'); + if (!output && document.body) { output = document.createElement('pre'); output.id = 'rtc-test'; output.style.cssText = 'position:fixed;right:0;bottom:0;max-height:110px;max-width:460px;overflow:auto;background:white;color:black;font:10px monospace;z-index:100;opacity:.85'; document.body.append(output); } + if (output) output.textContent = JSON.stringify({ sockets, trace: trace.slice(-12) }, null, 1); + } + window.WebSocket = class extends NativeSocket { + constructor(...args) { super(...args); this.addEventListener('open', () => { sockets++; report('socket-open'); }); this.addEventListener('close', () => { sockets--; report('socket-close'); }); } + }; + window.RTCPeerConnection = class extends NativePeer { + constructor(...args) { + super(...args); this.testId = ++nextPeer; report('pc-created', { id: this.testId }); + for (const event of ['connectionstatechange', 'iceconnectionstatechange', 'signalingstatechange']) this.addEventListener(event, () => report(event, { id: this.testId, connection: this.connectionState, ice: this.iceConnectionState, signaling: this.signalingState })); + this.addEventListener('datachannel', e => this.monitor(e.channel)); + } + createDataChannel(...args) { const channel = super.createDataChannel(...args); this.monitor(channel); return channel; } + setConfiguration(value) { try { super.setConfiguration(value); report('configuration-updated', { id: this.testId }); } catch (error) { report('configuration-error', { message: error.message, state: this.connectionState, before: this.getConfiguration().bundlePolicy, after: value.bundlePolicy }); throw error; } } + restartIce() { try { super.restartIce(); report('ice-restart', { id: this.testId }); } catch (error) { report('restart-error', { message: error.message, state: this.connectionState }); throw error; } } + monitor(channel) { + for (const event of ['open', 'close', 'error']) channel.addEventListener(event, () => report('channel-' + event, { id: this.testId })); + channel.addEventListener('message', event => { try { const m = JSON.parse(event.data); report('message', { id: this.testId, type: m.type, epoch: m.connectionEpoch ?? m.epoch, turn: m.turnId }); } catch {} }); + channel.addEventListener('open', () => { void this.getStats().then(stats => { + for (const item of stats.values()) if (item.type === 'candidate-pair' && item.state === 'succeeded') report('candidate', { id: this.testId, local: stats.get(item.localCandidateId)?.candidateType, remote: stats.get(item.remoteCandidateId)?.candidateType, rtt: item.currentRoundTripTime }); + }); }); + } + }; +})(); diff --git a/tests/peer-protocol.test.ts b/tests/peer-protocol.test.ts new file mode 100644 index 0000000..abc937f --- /dev/null +++ b/tests/peer-protocol.test.ts @@ -0,0 +1,39 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createTurnExchange } from '../src/shared/peer-protocol.js'; +import type { PeerMessage } from '../src/shared/p2p-types.js'; +import type { ActionId, Pair } from '../src/shared/types.js'; + +function pair() { + const toA: PeerMessage[] = [], toB: PeerMessage[] = [], resolvedA: Pair[] = [], resolvedB: Pair[] = []; + const base = { matchId: 'match', epoch: 0, turnId: 1, energy: [0, 0] as Pair }; + const a = createTurnExchange({ ...base, slot: 0, send: m => toB.push(m), onResolved: r => resolvedA.push(r) }); + const b = createTurnExchange({ ...base, slot: 1, send: m => toA.push(m), onResolved: r => resolvedB.push(r) }); + const pump = async () => { for (let i = 0; i < 20 && (toA.length || toB.length); i++) { while (toA.length) await a.receive(toA.shift()!); while (toB.length) await b.receive(toB.shift()!); } }; + return { a, b, toA, toB, resolvedA, resolvedB, pump }; +} +test('choices stay secret until both commitments and resolve identically exactly once', async () => { + const h = pair(); await h.a.commit('charge'); + assert.equal(h.toB.length, 1); assert.equal(h.toB[0].type, 'turn:commit'); + assert.equal('action' in h.toB[0], false); await h.b.receive(h.toB.shift()!); + assert.equal(h.toA.length, 0); + await h.b.commit('guard'); const commit = h.toA[0]; await h.pump(); + assert.deepEqual(h.resolvedA, [['charge', 'guard']]); assert.deepEqual(h.resolvedB, h.resolvedA); + await h.a.receive(commit); assert.equal(h.resolvedA.length, 1); +}); +test('missing input is committed as null and is distinct from missing network packets', async () => { + const h = pair(); await h.a.commit(null); await h.b.commit('charge'); await h.pump(); + assert.deepEqual(h.resolvedA, [[null, 'charge']]); + const silent = pair(); await silent.a.commit(null); assert.equal(silent.resolvedA.length, 0); +}); +test('tampered reveal and changed commitment are rejected; stale epochs never resolve', async () => { + const h = pair(); await h.a.commit('charge'); await h.b.commit('guard'); + const original = h.toA[0]; + await h.a.receive({ ...original, connectionEpoch: 99 }); assert.equal(h.toB.length, 1); + await h.a.receive(h.toA.shift()!); + await h.b.receive(h.toB.shift()!); + const reveal = h.toA.find(m => m.type === 'turn:reveal')!; + await assert.rejects(h.a.receive({ ...reveal, action: 'charge' } as PeerMessage), /COMMITMENT_MISMATCH/); + await assert.rejects(h.a.receive({ ...original, digest: '0'.repeat(64) } as PeerMessage), /COMMITMENT_CHANGED/); + assert.equal(h.resolvedA.length, 0); +}); diff --git a/tests/peer.test.ts b/tests/peer.test.ts new file mode 100644 index 0000000..e162827 --- /dev/null +++ b/tests/peer.test.ts @@ -0,0 +1,120 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createPeer } from '../src/client/peer.js'; +import { PROTOCOL_VERSION } from '../constants/webrtc.js'; +import { RULES_VERSION } from '../constants/gameplay.js'; + +class Signal { + handlers = new Map(); peer?: Signal; connected = false; disconnected = false; + on(name: string, fn: Function) { this.handlers.set(name, [...this.handlers.get(name) ?? [], fn]); return this; } + once(name: string, fn: Function) { return this.on(name, fn); } + fire(name: string, data?: any) { for (const fn of this.handlers.get(name) ?? []) fn(data); } + connect() { this.connected = true; queueMicrotask(() => { this.fire('connect'); if (this.peer?.connected) { this.fire('signal:peers', { count: 2 }); this.peer.fire('signal:peers', { count: 2 }); } }); } + disconnect() { this.disconnected = true; this.connected = false; return this; } + timeout() { return this; } + async emitWithAck(_event: string, packet: any) { queueMicrotask(() => this.peer?.fire('signal', packet)); return { ok: true }; } + removeAllListeners() { this.handlers.clear(); return this; } +} +class Channel { + readyState = 'connecting'; onopen?: () => void; onmessage?: (event: { data: string }) => void; onclose?: () => void; + peer?: Channel; + send(data: string) { queueMicrotask(() => this.peer?.onmessage?.({ data })); } + close() { this.readyState = 'closed'; this.onclose?.(); } +} +class PC { + connectionState = 'new'; localDescription: any; remoteDescription: any; channel?: Channel; peer?: PC; + ondatachannel?: (event: any) => void; onicecandidate?: (event: any) => void; onconnectionstatechange?: () => void; + createDataChannel(_label: string, options: any) { assert.equal(options.ordered, true); assert.equal(options.maxRetransmits, undefined); return this.channel = new Channel(); } + async createOffer() { return { type: 'offer', sdp: 'offer' }; } + async createAnswer() { return { type: 'answer', sdp: 'answer' }; } + async setLocalDescription(value: any) { this.localDescription = value; } + async setRemoteDescription(value: any) { + this.remoteDescription = value; + if (value.type === 'answer') { + const remote = new Channel(); this.channel!.peer = remote; remote.peer = this.channel; + this.peer!.channel = remote; this.peer!.ondatachannel?.({ channel: remote }); + this.connectionState = this.peer!.connectionState = 'connected'; + this.channel!.readyState = remote.readyState = 'open'; this.channel!.onopen?.(); remote.onopen?.(); + } + } + async addIceCandidate() {} restartIce() {} close() { this.connectionState = 'closed'; } + async getStats() { return new Map(); } + getConfiguration() { return { iceServers: [], bundlePolicy: 'max-bundle', rtcpMuxPolicy: 'require' }; } + setConfiguration(value: any) { if (value.bundlePolicy !== 'max-bundle' || value.rtcpMuxPolicy !== 'require') throw new Error('INVALID_MODIFICATION'); } +} +test('peer connects reliable channel, closes signaling after mutual ready and keeps delivering data', async () => { + const sa = new Signal(), sb = new Signal(); sa.peer = sb; sb.peer = sa; + const pa = new PC(), pb = new PC(); pa.peer = pb; pb.peer = pa; + const config = { matchId: 'm1', roomId: 'r1', epoch: 0, iceServers: [] }; + const a = createPeer({ ...config, slot: 0, createSignaling: () => sa as any, createConnection: () => pa as any }); + const b = createPeer({ ...config, slot: 1, createSignaling: () => sb as any, createConnection: () => pb as any }); + try { + const messages: any[] = []; b.onMessage(m => messages.push(m)); + await Promise.all([a.connect(), b.connect()]); + assert.equal(sa.disconnected, true); assert.equal(sb.disconnected, true); + const message = { protocolVersion: PROTOCOL_VERSION, rulesVersion: RULES_VERSION, matchId: 'm1', connectionEpoch: 0, turnId: 1, type: 'turn:start' as const, startsAt: 1000 }; + a.send(message); await new Promise(r => setImmediate(r)); assert.deepEqual(messages, [message]); + assert.equal(pa.connectionState, 'connected'); + } finally { a.close(); b.close(); } +}); + +test('late ICE rejection after mutual ready does not invalidate a working data channel', async () => { + const sa = new Signal(), sb = new Signal(); sa.peer = sb; sb.peer = sa; + const pa = new PC(), pb = new PC(); pa.peer = pb; pb.peer = pa; + const normal = sa.emitWithAck.bind(sa); let rejectIce!: (error: Error) => void; + sa.emitWithAck = (event, packet) => packet.type === 'ice' ? new Promise((_resolve, reject) => { rejectIce = reject; }) : normal(event, packet); + const a = createPeer({ matchId: 'late', roomId: 'r', slot: 0, iceServers: [], createSignaling: () => sa as any, createConnection: () => pa as any }); + const b = createPeer({ matchId: 'late', roomId: 'r', slot: 1, iceServers: [], createSignaling: () => sb as any, createConnection: () => pb as any }); + try { + const connecting = a.connect(); pa.onicecandidate?.({ candidate: { toJSON: () => ({ candidate: 'test' }) } }); + await Promise.all([connecting, b.connect()]); + rejectIce(new Error('PEER_NOT_READY')); await new Promise(r => setImmediate(r)); + assert.equal(a.connected, true); assert.equal(b.connected, true); + } finally { a.close(); b.close(); } +}); + +test('an ICE failure from the previous epoch cannot reject the new connection attempt', async () => { + const sa = new Signal(), sb = new Signal(); sa.peer = sb; sb.peer = sa; + const pa = new PC(), pb = new PC(); pa.peer = pb; pb.peer = pa; + const normal = sa.emitWithAck.bind(sa); let rejectIce!: (error: Error) => void; + sa.emitWithAck = (event, packet) => packet.type === 'ice' ? new Promise((_resolve, reject) => { rejectIce = reject; }) : normal(event, packet); + const a = createPeer({ matchId: 'generation', roomId: 'r', slot: 0, iceServers: [], createSignaling: () => sa as any, createConnection: () => pa as any }); + const b = createPeer({ matchId: 'generation', roomId: 'r', slot: 1, iceServers: [], createSignaling: () => sb as any, createConnection: () => pb as any }); + try { + const first = a.connect(); pa.onicecandidate?.({ candidate: { toJSON: () => ({ candidate: 'old' }) } }); + await Promise.all([first, b.connect()]); + const recovery = a.restart(); let failed = false; void recovery.catch(() => { failed = true; }); + rejectIce(new Error('OLD_ICE')); await new Promise(r => setImmediate(r)); + assert.equal(failed, false); assert.equal(sa.connected, true); + await Promise.all([recovery, b.restart()]); assert.equal(a.connected, true); + } finally { a.close(); b.close(); } +}); + +test('ICE restart refreshes servers without changing immutable connection configuration', async () => { + const sa = new Signal(), sb = new Signal(); sa.peer = sb; sb.peer = sa; + const pa = new PC(), pb = new PC(); pa.peer = pb; pb.peer = pa; + const a = createPeer({ matchId: 'restart', roomId: 'r', slot: 0, iceServers: [], refreshIce: async () => [{ urls: 'stun:example.test' }], createSignaling: () => sa as any, createConnection: () => pa as any }); + const b = createPeer({ matchId: 'restart', roomId: 'r', slot: 1, iceServers: [], refreshIce: async () => [], createSignaling: () => sb as any, createConnection: () => pb as any }); + try { + await Promise.all([a.connect(), b.connect()]); + await Promise.all([a.restart(), b.restart()]); + assert.equal(a.connected, true); assert.equal(b.connected, true); assert.equal(a.epoch, 1); + } finally { a.close(); b.close(); } +}); + +test('recovery replaces a closed data channel and can replace a connection that rejects ICE reconfiguration', async () => { + const sa = new Signal(), sb = new Signal(); sa.peer = sb; sb.peer = sa; + const pairs = Array.from({ length: 2 }, () => { const a = new PC(), b = new PC(); a.peer = b; b.peer = a; return [a, b]; }); + let ai = 0, bi = 0; + pairs[0][0].setConfiguration = () => { throw new Error('InvalidModificationError'); }; + const a = createPeer({ matchId: 'fallback', roomId: 'r', slot: 0, iceServers: [], refreshIce: async () => [], createSignaling: () => sa as any, createConnection: () => pairs[ai++][0] as any }); + const b = createPeer({ matchId: 'fallback', roomId: 'r', slot: 1, iceServers: [], refreshIce: async () => [], createSignaling: () => sb as any, createConnection: () => pairs[bi++][1] as any }); + try { + await Promise.all([a.connect(), b.connect()]); + pairs[0][1].channel!.close(); + await Promise.all([a.restart(), b.restart()]); + assert.equal(a.connected, true); assert.equal(b.connected, true); + assert.equal(pairs[0][0].connectionState, 'closed'); assert.equal(pairs[0][1].connectionState, 'closed'); + assert.equal(ai, 2); assert.equal(bi, 2); + } finally { a.close(); b.close(); } +}); diff --git a/tests/preview-server.ts b/tests/preview-server.ts new file mode 100644 index 0000000..d269e4c --- /dev/null +++ b/tests/preview-server.ts @@ -0,0 +1,50 @@ +// Local UI fixture only. This entry point is never compiled into the production server. +import { createGameServer } from '../src/server/create-server.js'; +import { readFile } from 'node:fs/promises'; +import { build } from 'esbuild'; +const clientDirectory = process.env.UI_PREVIEW_CLIENT ?? 'dist/client'; +const app = await createGameServer({ mongoUri: process.env.TEST_MONGODB_URI!, databaseName: 'dragon_ball_test_preview_20261012', clientDirectory, adminUser: 'preview', adminPassword: 'preview-test-only', environment: 'test' }); +// Test-only credentials stay on the fixture server; normal production auth is tested separately. +const authorization = `Basic ${Buffer.from('preview:preview-test-only').toString('base64')}`; +app.get('/__test/peer', async (_request, reply) => { + const index = await readFile(clientDirectory + '/index.html', 'utf8'); + return reply.type('text/html; charset=utf-8').send(index.replace('', '')); +}); +app.get('/__test/peer-monitor.js', async (_request, reply) => reply.type('application/javascript').send(await readFile('tests/peer-browser-monitor.js', 'utf8'))); +app.get('/__test/admin', async (_request, reply) => { + const response = await app.inject({ url: '/admin/analytics', headers: { authorization } }); + const bootstrap = ''; + return reply.type('text/html; charset=utf-8').send(response.body.replace('', `${bootstrap}`)); +}); +app.get('/__test/admin-api/*', async (request, reply) => { + const tail = (request.params as { '*': string })['*']; + const query = request.url.includes('?') ? request.url.slice(request.url.indexOf('?')) : ''; + const response = await app.inject({ url: `/api/admin/analytics/${tail}${query}`, headers: { authorization } }); + return reply.code(response.statusCode).type('application/json').send(response.body); +}); +app.get('/__test/mobile', (request, reply) => { + const query = request.query as { width?: string; height?: string }; + const width = query.width === '320' ? 320 : 390, height = query.height === '568' ? 568 : 844; + return reply.type('text/html; charset=utf-8').send(`모바일 검증`); +}); +app.get('/__test/desktop', (_request, reply) => reply.type('text/html; charset=utf-8').send('PC 검증')); +app.get('/__test/admin-mobile', (_request, reply) => reply.type('text/html; charset=utf-8').send('관리자 모바일 검증')); +app.get('/__test/scenarios', async (_request, reply) => { + const index = await readFile(clientDirectory + '/index.html', 'utf8'); + const styles = index.match(/]+rel="stylesheet"[^>]*>/g)?.join('') ?? ''; + return reply.type('text/html; charset=utf-8').send(`UI 상태 검증${styles}
드래곤볼UI 상태 검증
`); +}); +app.get('/__test/scenarios.js', async (_request, reply) => { + const bundle = await build({ entryPoints: ['tests/ui-scenarios.ts'], bundle: true, write: false, format: 'esm', platform: 'browser', loader: { '.css': 'empty' } }); + return reply.type('application/javascript').send(bundle.outputFiles[0].text); +}); +app.get('/__test/scenarios-mobile', (request, reply) => { + const query = request.query as { width?: string; height?: string }; + const width = query.width === '320' ? 320 : 390, height = query.height === '568' ? 568 : 844; + return reply.type('text/html; charset=utf-8').send(`모바일 상태 검증`); +}); +app.get('/__test/scenarios-desktop', (_request, reply) => reply.type('text/html; charset=utf-8').send('PC 상태 검증')); +const port = Number(process.env.UI_PREVIEW_PORT ?? 3001); +await app.listen({ host: '127.0.0.1', port }); +console.log(`http://127.0.0.1:${port}`); +for (const signal of ['SIGINT', 'SIGTERM'] as const) process.once(signal, () => { void app.close(); }); diff --git a/tests/ranked-mode-race.test.ts b/tests/ranked-mode-race.test.ts new file mode 100644 index 0000000..e57ef06 --- /dev/null +++ b/tests/ranked-mode-race.test.ts @@ -0,0 +1,103 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import Fastify from 'fastify'; +import type { Db } from 'mongodb'; +import { registerGameApi, registerRoomApi } from '../src/server/game-api.js'; +import type { GameSessionStore } from '../src/server/game-session-store.js'; +import { createRankedService } from '../src/server/ranked-service.js'; +import type { RoomStore } from '../src/server/rooms.js'; +import type { Signaling } from '../src/server/signaling.js'; +import { ManualClock } from './helpers.js'; + +function deferred() { + let resolve!: () => void; + const promise = new Promise(done => { resolve = done; }); + return { promise, resolve }; +} + +// Only the persistence boundary is fake. Queue ownership, serialization, pairing, +// availability checks and HTTP routing all run through the production code. +function memoryDatabase(now: number) { + const records: Record[]> = { + game_sessions: [ + { _id: 'session-a', playerId: 'player-a', expiresAt: new Date(now + 60000) }, + { _id: 'session-b', playerId: 'player-b', expiresAt: new Date(now + 60000) }, + ], + player_profiles: [], ranked_matches: [], rooms: [], + }; + const matches = (doc: Record, query: Record = {}) => + Object.entries(query).every(([key, value]) => { + if (value && typeof value === 'object' && '$gt' in value) return doc[key] > value.$gt; + if (value && typeof value === 'object' && '$in' in value) { + return (Array.isArray(doc[key]) ? doc[key] : [doc[key]]).some(item => value.$in.includes(item)); + } + return Array.isArray(doc[key]) ? doc[key].includes(value) : doc[key] === value; + }); + const db = { collection(name: string) { + const data = records[name]; + return { + async createIndex() {}, + async findOne(query: Record) { return data.find(doc => matches(doc, query)) ?? null; }, + find(query?: Record) { + const rows = data.filter(doc => matches(doc, query)); + return { + sort() { return this; }, limit() { return this; }, + async next() { return rows[0] ?? null; }, async toArray() { return rows; }, + async *[Symbol.asyncIterator]() { yield* rows; }, + }; + }, + async updateOne(query: Record, update: Record) { + const doc = data.find(row => matches(row, query)); + if (doc) Object.assign(doc, update.$set ?? {}); + else data.push({ ...query, ...update.$setOnInsert, ...update.$set }); + }, + }; + } } as unknown as Db; + return { db, records }; +} + +for (const url of ['/api/game-session/matches', '/api/rooms', '/api/rooms/join']) { + test(`${url} reserves the player until its write completes before ranked pairing`, { timeout: 5000 }, async () => { + const clock = new ManualClock(), { db, records } = memoryDatabase(clock.now()); + const ranked = await createRankedService(db, clock, 'test'), app = Fastify(); + const entered = deferred(), release = deferred(); + const regularMatch = { matchId: 'bot-match', requestId: 'request-a', mode: 'bot', expectedPreviousSeq: 0 }; + const room = { id: 'friend-room' }; + const write = async () => { + entered.resolve(); + await release.promise; + if (url === '/api/game-session/matches') records.game_sessions[0].activeMatch = regularMatch; + else records.rooms.push({ _id: room.id, members: ['session-a'], open: true, expiresAt: new Date(clock.now() + 60000) }); + return url === '/api/game-session/matches' ? regularMatch : room; + }; + const sessions = { + async get() { return { gameSessionId: 'session-a', playerId: 'player-a' }; }, + register: write, + } as unknown as GameSessionStore; + const rooms = { create: write, join: write } as unknown as RoomStore; + registerGameApi(app, sessions, undefined, ranked); + registerRoomApi(app, sessions, rooms, { async notifyRoom() {} } as unknown as Signaling, ranked); + try { + await ranked.join('player-b', 'session-b'); + const regular = app.inject({ method: 'POST', url, + headers: { authorization: `Bearer ${'a'.repeat(43)}`, 'x-game-session': 'session-a' }, + payload: url === '/api/game-session/matches' ? regularMatch : { requestId: 'request-a', code: 'ABCDEF' }, + }).then(response => response); + await entered.promise; + const pairing = ranked.join('player-a', 'session-a').then( + status => ({ status, error: undefined }), error => ({ status: undefined, error }), + ); + // Drain queued work while the regular-mode write is explicitly held open. + // With the old check-then-write guard, ranked pairing completes in this gap. + await new Promise(resolve => setImmediate(resolve)); + release.resolve(); + assert.equal((await regular).statusCode, 200); + const outcome = await pairing; + assert.equal(outcome.error?.code, 'MATCH_PENDING', `ranked entered during the write: ${outcome.status?.status}`); + assert.equal((await ranked.status('player-b', 'session-b')).status, 'queued'); + } finally { + release.resolve(); + await app.close(); await ranked.close(); + } + }); +} diff --git a/tests/ranked.test.ts b/tests/ranked.test.ts new file mode 100644 index 0000000..249e3ee --- /dev/null +++ b/tests/ranked.test.ts @@ -0,0 +1,547 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { randomBytes, randomUUID } from 'node:crypto'; +import Fastify from 'fastify'; +import { MongoClient } from 'mongodb'; +import { MATCH_COUNTDOWN_MS, REVEAL_DURATION_MS, TURN_DURATION_MS } from '../constants/gameplay.js'; +import { RANKED_FINISHED_TTL_MS, RANKED_QUEUE_TTL_MS } from '../constants/ranked.js'; +import { createRankedService } from '../src/server/ranked-service.js'; +import { registerRankedApi } from '../src/server/ranked-api.js'; +import { createGameSessionStore } from '../src/server/game-session-store.js'; +import { registerGameApi } from '../src/server/game-api.js'; +import { ManualClock } from './helpers.js'; +import { normalizeAppearance } from '../src/shared/cosmetics.js'; +import { EQUIPMENT_IDS } from '../constants/cosmetics.js'; +import { createRoomStore } from '../src/server/rooms.js'; + +const integration = { skip: !process.env.TEST_MONGODB_URI }; +async function fixture() { + const client = await new MongoClient(process.env.TEST_MONGODB_URI!, { serverSelectionTimeoutMS: 5000 }).connect(); + const db = client.db(`dragon_ball_test_ranked_${randomUUID().replaceAll('-', '')}`); + const clock = new ManualClock(); + const sessions = await createGameSessionStore(db, clock.now, 'test'); + const service = await createRankedService(db, clock, 'test'); + const a = await sessions.create(randomBytes(32).toString('base64url'), 'player-a'); + const a2 = await sessions.create(randomBytes(32).toString('base64url'), 'player-a'); + const b = await sessions.create(randomBytes(32).toString('base64url'), 'player-b'); + return { db, clock, sessions, service, a, a2, b, + async close() { await service.close(); try { await db.dropDatabase(); } finally { await client.close(); } }, + }; +} + +test('ranked pairing is serialized by player identity across tabs and survives reload', integration, async () => { + const f = await fixture(); + try { + const { service: s, a, a2, b } = f; + const first = await Promise.all([s.join('player-a', a.gameSessionId), s.join('player-a', a2.gameSessionId)]); + assert.ok(first.every(value => value.status === 'queued')); + await assert.rejects(s.assertAvailable('player-a'), /MATCH_PENDING/); + const paired = await s.join('player-b', b.gameSessionId); + const restored = await s.status('player-a', a2.gameSessionId); + assert.equal(paired.match?.matchId, restored.match?.matchId); + assert.notEqual(paired.match?.selfIndex, restored.match?.selfIndex); + assert.equal(restored.match?.mode, 'friend'); assert.equal(restored.match?.ranked, true); + assert.equal((await s.join('player-a', a.gameSessionId)).match?.matchId, paired.match?.matchId); + await assert.rejects(s.cancel('player-a', a.gameSessionId), /MATCH_PENDING/); + await assert.rejects(s.status('player-a', b.gameSessionId), /INVALID_SESSION/); + assert.equal((await s.profile('player-a')).stats.wins, 0); + } finally { await f.close(); } +}); + +test('bot and friend wins never change ranked standings, profile stats or cosmetic rewards', integration, async () => { + const f = await fixture(); + try { + await f.service.join('player-a', f.a.gameSessionId); + const rankedId = (await f.service.join('player-b', f.b.gameSessionId)).match!.matchId; + await f.service.leave('player-b', f.b.gameSessionId, rankedId); + await f.service.leave('player-a', f.a.gameSessionId, rankedId); + const before = await f.service.profile('player-a'); + const leaderboard = await f.service.leaderboard('player-a'); + const rankedRecords = await f.db.collection('ranked_matches').find().toArray(); + assert.equal(before.stats.wins, 1); assert.equal(before.stats.best, 1); + const rooms = await createRoomStore(f.db, f.clock.now); + // Seed only ordinary-session history in this isolated test DB, then record the + // hundredth win through each real result path for the same persistent playerId. + for (const [mode, session] of [['bot', f.a], ['friend', f.a2]] as const) { + await f.db.collection('game_sessions').updateOne({ _id: session.gameSessionId }, { $set: { + [`stats.${mode}`]: { wins: 99, current: 99, best: 99, losses: 0, draws: 0 }, + } }); + const room = mode === 'friend' ? await rooms.create(session.gameSessionId, randomUUID()) : undefined; + if (room) await rooms.join(f.b.gameSessionId, room.code); + const match = await f.sessions.register(session, { mode, requestId: randomUUID(), expectedPreviousSeq: 0, + matchId: room?.currentMatchId ?? randomUUID(), ...(room ? { roomId: room.id } : {}), + }); + const recorded = await f.sessions.record(session, { ...match, outcome: 'win', reason: 'hit' }); + assert.equal(recorded.stats[mode].wins, 100); assert.equal(recorded.stats[mode].best, 100); + if (room) await rooms.leave(room.id, session.gameSessionId); + assert.deepEqual(await f.service.profile('player-a'), before); + assert.deepEqual(await f.service.leaderboard('player-a'), leaderboard); + } + assert.deepEqual(await f.db.collection('ranked_matches').find().toArray(), rankedRecords); + assert.deepEqual(before.unlockedEquipment, []); assert.deepEqual(before.unlockedColors, ['white']); + for (const equipment of EQUIPMENT_IDS) { + await assert.rejects(f.service.updateProfile('player-a', { appearance: { + ...before.appearance, equipment: [equipment], + } }), (error: any) => error.statusCode === 403 && error.code === 'EQUIPMENT_LOCKED'); + } + await assert.rejects(f.service.updateProfile('player-a', { appearance: { ...before.appearance, colorId: 'red' } }), /COLOR_LOCKED/); + } finally { await f.close(); } +}); + +test('profiles keep generated names and validate unlocked appearances, persistence and pairing snapshots', integration, async () => { + const f = await fixture(); + try { + const s = f.service; + const original = await s.profile('player-a'); + assert.deepEqual(original.appearance, normalizeAppearance({ characterId: 0, colorId: 'white' })); + assert.deepEqual(original.unlockedEquipment, []); + assert.match(original.displayName, /^[가-힣]+-[0-9]{4}$/); + for (const displayName of ['손오공', '광고사이트', '', null, undefined]) { + await assert.rejects(s.updateProfile('player-a', { displayName } as any), /PROFILE_NAME_READONLY/); + } + for (const appearance of [{ characterId: 2, colorId: 'white' }, { characterId: 0, colorId: 'bogus' }, { characterId: 0, colorId: 'orange' }]) { + await assert.rejects(s.updateProfile('player-a', { appearance: appearance as any }), /INVALID_APPEARANCE|COLOR_LOCKED/); + } + const selected = await s.updateProfile('player-a', { appearance: { characterId: 1, colorId: 'white' } }); + await s.join('player-a', f.a.gameSessionId); + const paired = await s.join('player-b', f.b.gameSessionId); + await s.updateProfile('player-a', { appearance: { characterId: 0, colorId: 'white' } }); + assert.deepEqual(paired.match?.appearances?.[0], selected.appearance); + assert.equal((await f.db.collection('player_profiles').findOne({ _id: 'player-a' as any }))?.displayName, original.displayName); + await s.close(); + const restarted = await createRankedService(f.db, f.clock, 'test'); + try { + assert.equal((await restarted.profile('player-a')).displayName, original.displayName); + assert.equal((await restarted.status('player-a', f.a.gameSessionId)).status, 'idle'); + assert.equal((await restarted.profile('player-a')).stats.wins, 0); + } finally { await restarted.close(); } + } finally { await f.close(); } +}); + +test('startup migrates old custom and default names including nonvisiting leaderboard players exactly once', integration, async () => { + const f = await fixture(); + try { + await f.service.updateProfile('player-a', { appearance: { characterId: 1, colorId: 'white' } }); + await f.service.join('player-a', f.a.gameSessionId); + const id = (await f.service.join('player-b', f.b.gameSessionId)).match!.matchId; + await f.service.leave('player-b', f.b.gameSessionId, id); + const expectedStats = (await f.service.profile('player-a')).stats; + const records = await f.db.collection('ranked_matches').find().toArray(); + await f.service.close(); + const profiles = f.db.collection('player_profiles'); + await profiles.updateOne({ _id: 'player-a' }, { $set: { displayName: '광고.example' }, $unset: { nameVersion: '' } }); + await profiles.updateOne({ _id: 'player-b' }, { $set: { displayName: '무도가', nameVersion: 0 } }); + const restarted = await createRankedService(f.db, f.clock, 'test'); + let names: string[]; + try { + // Neither old profile is visited before the leaderboard request from a new viewer. + const board = await restarted.leaderboard('new-viewer'); + names = board.entries.map(entry => entry.displayName); + assert.equal(names.length, 2); + for (const name of names) assert.match(name, /^[가-힣]+-[0-9]{4}$/); + assert.equal(board.entries[0].wins, 1); + const profile = await restarted.profile('player-a'); + assert.deepEqual(profile.stats, expectedStats); + assert.deepEqual(profile.appearance, normalizeAppearance({ characterId: 1, colorId: 'white', skinTone: 'light', hairStyle: 'black' })); + assert.deepEqual(await f.db.collection('ranked_matches').find().toArray(), records); + assert.equal((await profiles.findOne({ _id: 'player-b' })).nameVersion, 1); + } finally { await restarted.close(); } + const again = await createRankedService(f.db, f.clock, 'test'); + try { assert.deepEqual((await again.leaderboard('new-viewer')).entries.map(entry => entry.displayName), names!); } + finally { await again.close(); } + } finally { await f.close(); } +}); + +test('hair is free on both characters, malformed cosmetics and unearned equipment are rejected', integration, async () => { + const f = await fixture(); + try { + const base = { characterId: 0 as const, colorId: 'white' }; + for (const invalid of [{ hairStyle: null }, { hairStyle: 'long' }, { hairStyle: 0 }, + { equipment: null }, { equipment: 'shoes' }, { equipment: ['unknown'] }, { equipment: ['shoes', 'shoes'] }, { equipment: [0] }]) { + await assert.rejects(f.service.updateProfile('player-a', { appearance: { ...base, ...invalid } as any }), + (error: any) => error.code === 'INVALID_APPEARANCE' && error.statusCode === 400); + } + for (const id of EQUIPMENT_IDS) { + await assert.rejects(f.service.updateProfile('player-a', { + appearance: { ...base, equipment: [id] }, stats: { wins: 999, best: 999 }, unlockedEquipment: [...EQUIPMENT_IDS], + } as any), (error: any) => error.code === 'EQUIPMENT_LOCKED' && error.statusCode === 403); + } + const original = await f.service.profile('player-a'); + for (const characterId of [0, 1] as const) { + for (const hairStyle of ['black', 'white', 'bald', 'default'] as const) { + const profile = await f.service.updateProfile('player-a', { appearance: { ...base, characterId, hairStyle } }); + assert.equal(profile.appearance.hairStyle, hairStyle === 'default' ? characterId === 0 ? 'black' : 'white' : hairStyle); + assert.equal(profile.appearance.characterId, characterId); + assert.deepEqual(profile.appearance.equipment, []); assert.deepEqual(profile.stats, original.stats); + } + } + await f.service.updateProfile('player-a', { appearance: { ...base, hairStyle: 'bald' } }); + const legacyUpdate = await f.service.updateProfile('player-a', { appearance: { ...base, characterId: 1 } }); + assert.equal(legacyUpdate.appearance.hairStyle, 'bald'); + assert.equal(legacyUpdate.displayName, original.displayName); + await f.service.close(); + const restarted = await createRankedService(f.db, f.clock, 'test'); + try { assert.deepEqual(await restarted.profile('player-a'), legacyUpdate); } + finally { await restarted.close(); } + } finally { await f.close(); } +}); + +test('equipment rewards use canonical thresholds, toggle independently, persist and snapshot on pairing', integration, async () => { + const f = await fixture(); + try { + const s = f.service; + const basicEquipment = ['shoes', 'wristbands', 'headband'] as const; + for (let wins = 1; wins <= 20; wins++) { + await s.join('player-a', f.a.gameSessionId); + const id = (await s.join('player-b', f.b.gameSessionId)).match!.matchId; + await s.leave('player-b', f.b.gameSessionId, id); + await s.leave('player-a', f.a.gameSessionId, id); + const profile = await s.profile('player-a'); + assert.deepEqual(profile.unlockedEquipment, wins < 3 ? [] : wins < 5 ? ['wristbands'] : wins < 20 ? ['shoes', 'wristbands'] : [...basicEquipment]); + } + const base = { characterId: 1 as const, colorId: 'blue', hairStyle: 'bald' as const, skinTone: 'light' as const }; + for (let mask = 0; mask < 8; mask++) { + const equipment = basicEquipment.filter((_, index) => mask & (1 << index)); + const profile = await s.updateProfile('player-a', { appearance: { ...base, equipment } }); + assert.deepEqual(profile.appearance, { ...base, equipment }); + } + // A legacy color/character update must not unequip the new fields. + const equipped = await s.updateProfile('player-a', { appearance: { characterId: 1, colorId: 'blue' } }); + assert.deepEqual(equipped.appearance, { ...base, equipment: [...basicEquipment] }); + const stored = await f.db.collection('player_profiles').findOne({ _id: 'player-a' }); + assert.deepEqual(stored.appearance, equipped.appearance); + await s.join('player-a', f.a.gameSessionId); + const paired = await s.join('player-b', f.b.gameSessionId); + await s.updateProfile('player-a', { appearance: { ...base, hairStyle: 'default', equipment: [] } }); + assert.deepEqual(paired.match!.appearances![0], equipped.appearance); + assert.deepEqual((await s.status('player-a', f.a2.gameSessionId)).match!.appearances![0], equipped.appearance); + await s.updateProfile('player-a', { appearance: equipped.appearance }); + await s.close(); + const restarted = await createRankedService(f.db, f.clock, 'test'); + try { assert.deepEqual(await restarted.profile('player-a'), equipped); } + finally { await restarted.close(); } + // Existing profiles lacking the new fields retain earned rewards, color, stats and name. + await f.db.collection('player_profiles').updateOne({ _id: 'player-a' }, { + $unset: { 'appearance.hairStyle': '', 'appearance.equipment': '', 'appearance.skinTone': '' }, + }); + const migrated = await createRankedService(f.db, f.clock, 'test'); + try { + assert.deepEqual(await migrated.profile('player-a'), { ...equipped, + appearance: { characterId: 1, colorId: 'blue', hairStyle: 'white', skinTone: 'tan', equipment: [] }, + }); + } finally { await migrated.close(); } + } finally { await f.close(); } +}); + +test('skin and independent white hair validate, preserve legacy profiles, persist and snapshot on pairing', integration, async () => { + const f = await fixture(); + try { + const original = await f.service.profile('player-a'); + await f.db.collection('player_profiles').updateOne({ _id: 'player-a' }, { $set: { + appearance: { characterId: 1, colorId: 'white', hairStyle: 'default', equipment: [] }, + } }); + const legacy = await f.service.profile('player-a'); + assert.deepEqual(legacy.appearance, { characterId: 1, colorId: 'white', skinTone: 'tan', hairStyle: 'white', equipment: [] }); + assert.equal(legacy.displayName, original.displayName); assert.deepEqual(legacy.stats, original.stats); + for (const skinTone of [null, 'invalid', 0]) { + await assert.rejects(f.service.updateProfile('player-a', { appearance: { ...legacy.appearance, skinTone } as any }), + (error: any) => error.code === 'INVALID_APPEARANCE' && error.statusCode === 400); + } + for (const skinTone of ['light', 'tan'] as const) for (const hairStyle of ['black', 'white', 'bald'] as const) { + const changed = await f.service.updateProfile('player-a', { appearance: { ...legacy.appearance, skinTone, hairStyle } }); + assert.equal(changed.appearance.characterId, 1); + assert.equal(changed.appearance.skinTone, skinTone); assert.equal(changed.appearance.hairStyle, hairStyle); + } + const selected = await f.service.updateProfile('player-a', { appearance: { + characterId: 0, colorId: 'white', skinTone: 'tan', hairStyle: 'white', equipment: [], + } }); + assert.deepEqual((await f.service.updateProfile('player-a', { appearance: { characterId: 0, colorId: 'white' } })).appearance, selected.appearance); + await f.service.join('player-a', f.a.gameSessionId); + const paired = await f.service.join('player-b', f.b.gameSessionId); + await f.service.updateProfile('player-a', { appearance: { ...selected.appearance, skinTone: 'light', hairStyle: 'black' } }); + assert.deepEqual(paired.match!.appearances![0], selected.appearance); + assert.deepEqual((await f.service.status('player-a', f.a2.gameSessionId)).match!.appearances![0], selected.appearance); + await f.service.updateProfile('player-a', { appearance: selected.appearance }); + await f.service.close(); + const restarted = await createRankedService(f.db, f.clock, 'test'); + try { assert.deepEqual(await restarted.profile('player-a'), selected); } + finally { await restarted.close(); } + } finally { await f.close(); } +}); + +test('ranked eyewear persists after streak loss, rejects locked or dual eyewear, and snapshots on pairing', integration, async () => { + const f = await fixture(); + const original = await f.service.profile('player-a'); + await f.service.close(); + // Canonical historical outcomes exercise aggregation without playing 100 live games. + let order = 0; + const records = [49, 50, 99, 100].flatMap(best => { + const playerId = best === 100 ? 'player-a' : `best-${best}`; + return Array.from({ length: best + 1 }, (_, index) => ({ + _id: randomUUID(), playerIds: [playerId, 'historical-opponent'], + appearances: [normalizeAppearance(undefined), normalizeAppearance(undefined)], + winnerIndex: index === best ? 1 : 0, reason: 'hit', order: ++order, completedAt: new Date(f.clock.now()), environment: 'test', + })); + }); + await f.db.collection('ranked_matches').insertMany(records); + const s = await createRankedService(f.db, f.clock, 'test'); + try { + const base = { characterId: 1 as const, colorId: 'white', hairStyle: 'bald' as const }; + for (const best of [49, 50, 99, 100]) { + const playerId = best === 100 ? 'player-a' : `best-${best}`; + const profile = await s.profile(playerId); + assert.equal(profile.stats.best, best); assert.equal(profile.stats.current, 0); + for (const [item, threshold] of [['glasses', 50], ['sunglasses', 100]] as const) { + const update = () => s.updateProfile(playerId, { appearance: { ...base, equipment: [item] } }); + assert.equal(profile.unlockedEquipment.includes(item), best >= threshold); + if (best < threshold) await assert.rejects(update(), (error: any) => error.statusCode === 403 && error.code === 'EQUIPMENT_LOCKED'); + else assert.deepEqual((await update()).appearance.equipment, [item]); + } + } + await assert.rejects(s.updateProfile('player-a', { appearance: { ...base, equipment: ['sunglasses', 'glasses'] } }), + (error: any) => error.statusCode === 400 && error.code === 'INVALID_APPEARANCE'); + const equipped = await s.updateProfile('player-a', { appearance: { ...base, equipment: ['shoes', 'headband', 'sunglasses'] } }); + assert.equal(equipped.displayName, original.displayName); + assert.deepEqual((await s.updateProfile('player-a', { appearance: { characterId: 1, colorId: 'white' } })).appearance, equipped.appearance); + await s.join('player-a', f.a.gameSessionId); + const paired = await s.join('player-b', f.b.gameSessionId); + await s.updateProfile('player-a', { appearance: { ...base, equipment: ['glasses'] } }); + assert.deepEqual((await s.status('player-a', f.a2.gameSessionId)).match!.appearances![0], equipped.appearance); + assert.deepEqual(paired.match!.appearances![0], equipped.appearance); + assert.deepEqual((await s.updateProfile('player-a', { appearance: { ...base, equipment: [] } })).appearance.equipment, []); + await s.updateProfile('player-a', { appearance: equipped.appearance }); + await s.close(); + const restarted = await createRankedService(f.db, f.clock, 'test'); + try { assert.deepEqual(await restarted.profile('player-a'), equipped); } + finally { await restarted.close(); } + } finally { await s.close(); await f.close(); } +}); + +test('server hides choices, checks costs and deadlines, resolves once and awards canonical wins', integration, async () => { + const f = await fixture(); + try { + const { service: s, a, a2, b, clock } = f; + await s.join('player-a', a.gameSessionId); + const id = (await s.join('player-b', b.gameSessionId)).match!.matchId; + await assert.rejects(s.action('player-a', a.gameSessionId, id, 0, 'charge'), /INVALID_ACTION/); + await clock.advance(MATCH_COUNTDOWN_MS); + await assert.rejects(s.action('player-a', a.gameSessionId, id, 1, 'beam'), /INVALID_ACTION/); + const version = (await s.status('player-a', a.gameSessionId)).match!.version; + const locked = await s.action('player-a', a.gameSessionId, id, 1, 'charge'); + assert.ok(locked.match!.version > version); + await assert.rejects(s.action('player-a', a2.gameSessionId, id, 1, 'guard'), /ACTION_LOCKED/); + const hidden = (await s.status('player-b', b.gameSessionId)).match!; + assert.equal(hidden.ownAction, null); assert.equal(hidden.reveal, undefined); + assert.equal(hidden.version, version); + await s.action('player-b', b.gameSessionId, id, 1, 'charge'); + await clock.advance(TURN_DURATION_MS); + assert.deepEqual((await s.status('player-b', b.gameSessionId)).match?.reveal?.actions, ['charge', 'charge']); + await clock.advance(REVEAL_DURATION_MS); + await s.action('player-a', a.gameSessionId, id, 2, 'blast'); + await s.action('player-b', b.gameSessionId, id, 2, 'charge'); + await clock.advance(TURN_DURATION_MS); + await Promise.all(Array.from({ length: 8 }, () => s.status('player-a', a.gameSessionId))); + const ended = await s.status('player-b', b.gameSessionId); + assert.equal(ended.status, 'matched'); assert.equal(ended.match?.phase, 'finished'); + assert.equal(ended.match?.finished?.winnerIndex, 0); + assert.equal(ended.match?.finished?.saved, true); + assert.deepEqual((await s.profile('player-a')).stats, { wins: 1, losses: 0, draws: 0, current: 1, best: 1 }); + assert.equal(ended.profile.stats.losses, 1); + assert.deepEqual(ended.match?.finished?.stats.friend, ended.profile.stats); + assert.equal(await f.db.collection('ranked_matches').countDocuments(), 1); + await s.profile('visitor-only'); + const board = await s.leaderboard('player-a'); + assert.equal(board.entries.length, 2); + assert.equal((await s.leaderboard('visitor-only')).self, undefined); + assert.equal(board.entries[0].playerId, 'player-a'); assert.equal(board.self?.rank, 1); + await assert.rejects(s.action('player-a', a.gameSessionId, id, 2, 'guard'), /INVALID_ACTION/); + await s.leave('player-a', a.gameSessionId, id); + assert.equal((await s.leave('player-a', a2.gameSessionId, id)).status, 'idle'); + assert.equal((await s.status('player-b', b.gameSessionId)).status, 'matched'); + } finally { await f.close(); } +}); + +test('finished matches retain results on reload then expire when abandoned', integration, async () => { + const f = await fixture(); + try { + await f.service.join('player-a', f.a.gameSessionId); + const id = (await f.service.join('player-b', f.b.gameSessionId)).match!.matchId; + await f.clock.advance(MATCH_COUNTDOWN_MS + TURN_DURATION_MS); + assert.equal((await f.service.status('player-a', f.a2.gameSessionId)).match?.finished?.reason, 'double_timeout'); + await f.clock.advance(RANKED_FINISHED_TTL_MS - 1); + assert.equal((await f.service.status('player-a', f.a2.gameSessionId)).match?.matchId, id); + await f.clock.advance(1); + await f.service.assertAvailable('player-b'); + assert.equal((await f.service.status('player-b', f.b.gameSessionId)).status, 'idle'); + await assert.rejects(f.service.assertAvailable('player-a'), /MATCH_PENDING/); + await f.clock.advance(RANKED_FINISHED_TTL_MS); + assert.equal((await f.service.status('player-a', f.a.gameSessionId)).status, 'idle'); + assert.equal(f.clock.timers.size, 0); + assert.equal((await f.service.profile('player-a')).stats.draws, 1); + assert.equal(await f.db.collection('ranked_matches').countDocuments(), 1); + } finally { await f.close(); } +}); + +test('deadline rejects late actions and duplicate terminal writes survive an ambiguous database response', integration, async () => { + const f = await fixture(); + await f.service.close(); + const collection = f.db.collection('ranked_matches'); + let loseResponse = true; + const db = new Proxy(f.db, { get(target, key) { + if (key === 'collection') return (name: string) => name !== 'ranked_matches' ? target.collection(name) : new Proxy(collection, { + get(records, method) { + if (method === 'updateOne') return async (...args: Parameters) => { + const result = await records.updateOne(...args); + if (loseResponse) { loseResponse = false; throw new Error('response lost after write'); } + return result; + }; + const value = Reflect.get(records, method); + return typeof value === 'function' ? value.bind(records) : value; + }, + }); + const value = Reflect.get(target, key); + return typeof value === 'function' ? value.bind(target) : value; + } }); + const s = await createRankedService(db, f.clock, 'test'); + try { + await s.join('player-a', f.a.gameSessionId); + const id = (await s.join('player-b', f.b.gameSessionId)).match!.matchId; + await f.clock.advance(MATCH_COUNTDOWN_MS); + await s.action('player-a', f.a.gameSessionId, id, 1, 'guard'); + // Jump to the deadline without executing timers: the request itself must settle it. + f.clock.time += TURN_DURATION_MS; + await assert.rejects(s.action('player-b', f.b.gameSessionId, id, 1, 'guard'), /response lost after write/); + assert.equal((await s.profile('player-a')).stats.wins, 1); + await assert.rejects(s.action('player-b', f.b.gameSessionId, id, 1, 'guard'), /INVALID_ACTION/); + assert.equal((await s.status('player-b', f.b.gameSessionId)).match?.finished?.reason, 'timeout'); + assert.equal(await collection.countDocuments(), 1); + assert.equal((await s.profile('player-a')).stats.wins, 1); + assert.equal((await s.profile('player-b')).stats.losses, 1); + } finally { await s.close(); await f.close(); } +}); + +test('queue cancel, heartbeat expiry and existing games exclude unavailable players', integration, async () => { + const f = await fixture(); + try { + const { service: s, a, b, clock } = f; + await s.join('player-a', a.gameSessionId); + assert.equal((await s.cancel('player-a', a.gameSessionId)).status, 'idle'); + await s.join('player-a', a.gameSessionId); + await clock.advance(RANKED_QUEUE_TTL_MS - 1); + assert.equal((await s.status('player-a', a.gameSessionId)).status, 'queued'); + await clock.advance(RANKED_QUEUE_TTL_MS); + assert.equal((await s.join('player-b', b.gameSessionId)).status, 'queued'); + assert.equal((await s.status('player-a', a.gameSessionId)).status, 'idle'); + await s.cancel('player-b', b.gameSessionId); + await f.sessions.register(a, { requestId: 'bot-request', matchId: 'bot-match', mode: 'bot', expectedPreviousSeq: 0 }); + await assert.rejects(s.join('player-a', f.a2.gameSessionId), /MATCH_PENDING/); + await f.db.collection('rooms').insertOne({ members: [b.gameSessionId], open: true, expiresAt: new Date(clock.now() + 60000) }); + await assert.rejects(s.join('player-b', b.gameSessionId), /MATCH_PENDING/); + } finally { await f.close(); } +}); + +test('another authenticated tab keeps the player queue alive after the original session expires', integration, async () => { + const f = await fixture(); + try { + await f.service.join('player-a', f.a.gameSessionId); + await f.db.collection('game_sessions').updateOne({ _id: f.a.gameSessionId }, { $set: { expiresAt: new Date(f.clock.now() - 1) } }); + assert.equal((await f.service.status('player-a', f.a2.gameSessionId)).status, 'queued'); + assert.equal((await f.service.join('player-b', f.b.gameSessionId)).status, 'matched'); + } finally { await f.close(); } +}); + +test('timeouts progress without polling, unlock colors only from authoritative records, and close aborts', integration, async () => { + const f = await fixture(); + try { + const { service: s, a, b, clock } = f; + for (let n = 0; n < 5; n++) { + await s.join('player-a', a.gameSessionId); + const id = (await s.join('player-b', b.gameSessionId)).match!.matchId; + await clock.advance(MATCH_COUNTDOWN_MS); + await s.action('player-a', a.gameSessionId, id, 1, 'guard'); + await clock.advance(TURN_DURATION_MS); + assert.equal((await s.status('player-b', b.gameSessionId)).match?.finished?.reason, 'timeout'); + await s.leave('player-a', a.gameSessionId, id); await s.leave('player-b', b.gameSessionId, id); + } + const profile = await s.profile('player-a'); + assert.equal(profile.stats.wins, 5); assert.equal(profile.stats.best, 5); + assert.ok(profile.unlockedColors.length > 1); + await s.updateProfile('player-a', { appearance: { characterId: 1, colorId: profile.unlockedColors.find(id => id !== 'white')! } }); + await s.join('player-a', a.gameSessionId); await s.join('player-b', b.gameSessionId); + await s.close(); + const restarted = await createRankedService(f.db, clock, 'test'); + try { assert.equal((await restarted.profile('player-a')).stats.wins, 5); } + finally { await restarted.close(); } + } finally { await f.close(); } +}); + +test('ranked HTTP authenticates identity and ignores client results for ranked awards', integration, async () => { + const f = await fixture(), app = Fastify(); + try { + registerRankedApi(app, f.sessions, f.service); registerGameApi(app, f.sessions); + assert.equal((await app.inject({ method: 'GET', url: '/api/profile' })).statusCode, 401); + const headers = { authorization: `Bearer ${f.a.token}`, 'x-game-session': f.a.gameSessionId }; + const original = await f.service.profile('player-a'); + const locked = await app.inject({ method: 'POST', url: '/api/profile', headers, payload: { + appearance: { characterId: 0, colorId: 'white', equipment: ['headband'] }, stats: { wins: 999, best: 999 }, + } }); + assert.equal(locked.statusCode, 403); assert.equal(locked.json().code, 'EQUIPMENT_LOCKED'); + for (const displayName of ['광고사이트', '', '', null, { nameVersion: 1 }]) { + const rejected = await app.inject({ method: 'POST', url: '/api/profile', headers, payload: { + displayName, nameVersion: 1, appearance: { characterId: 1, colorId: 'white' }, + } }); + assert.equal(rejected.statusCode, 403); assert.equal(rejected.json().code, 'PROFILE_NAME_READONLY'); + } + assert.deepEqual(await f.service.profile('player-a'), original); + const profile = await app.inject({ method: 'POST', url: '/api/profile', headers, payload: { playerId: 'player-b', stats: { wins: 500 } } }); + assert.equal(profile.statusCode, 200); assert.equal(profile.json().data.playerId, 'player-a'); + await app.inject({ method: 'POST', url: '/api/ranked/queue', headers }); + const paired = await f.service.join('player-b', f.b.gameSessionId); + const report = await app.inject({ method: 'POST', url: '/api/game-session/results', headers, payload: { + matchId: paired.match!.matchId, seq: 1, mode: 'friend', outcome: 'win', reason: 'hit', + } }); + assert.notEqual(report.statusCode, 200); + assert.equal((await f.service.profile('player-a')).stats.wins, 0); + assert.equal((await app.inject({ method: 'POST', url: '/api/ranked/action', headers, payload: {} })).statusCode, 400); + assert.equal((await app.inject({ method: 'GET', url: '/api/ranked/leaderboard', headers })).statusCode, 200); + } finally { await app.close(); await f.close(); } +}); + +test('a slow leaderboard scan does not block match actions or deadline processing', integration, async () => { + const f = await fixture(); + await f.service.close(); + let release!: () => void, entered!: () => void; + const gate = new Promise(resolve => { release = resolve; }); + const started = new Promise(resolve => { entered = resolve; }); + const records = f.db.collection('ranked_matches'); + const db = new Proxy(f.db, { get(target, key) { + if (key === 'collection') return (name: string) => name !== 'ranked_matches' ? target.collection(name) : new Proxy(records, { + get(collection, method) { + if (method === 'find') return (filter?: Record) => { + const cursor = collection.find(filter ?? {}); + if (filter === undefined) { + const iterate = cursor[Symbol.asyncIterator].bind(cursor); + cursor[Symbol.asyncIterator] = async function* () { entered(); await gate; yield* iterate(); }; + } + return cursor; + }; + const value = Reflect.get(collection, method); + return typeof value === 'function' ? value.bind(collection) : value; + }, + }); + const value = Reflect.get(target, key); + return typeof value === 'function' ? value.bind(target) : value; + } }); + const s = await createRankedService(db, f.clock, 'test'); + try { + await s.join('player-a', f.a.gameSessionId); + const id = (await s.join('player-b', f.b.gameSessionId)).match!.matchId; + await f.clock.advance(MATCH_COUNTDOWN_MS); + const board = s.leaderboard('player-a'); await started; + const action = s.action('player-a', f.a.gameSessionId, id, 1, 'guard'); + let timer: ReturnType; + const progressed = await Promise.race([action.then(() => true), new Promise(resolve => { timer = setTimeout(() => resolve(false), 1000); })]); + clearTimeout(timer!); release(); await board; await action; + assert.equal(progressed, true, 'leaderboard must not own the gameplay operation lock'); + } finally { release(); await s.close(); await f.close(); } +}); diff --git a/tests/report.test.ts b/tests/report.test.ts new file mode 100644 index 0000000..b34371f --- /dev/null +++ b/tests/report.test.ts @@ -0,0 +1,38 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { randomUUID } from 'node:crypto'; +import { createStatsStore } from '../src/server/stats-store.js'; +import { createAnalyticsStore } from '../src/server/analytics-store.js'; +import { getAnalyticsReport } from '../src/server/analytics-report.js'; + +test('dashboard preserves visit analytics and counts current client matches once across participants', { skip: !process.env.TEST_MONGODB_URI }, async () => { + const store = await createStatsStore({ mongoUri: process.env.TEST_MONGODB_URI!, databaseName: `dragon_ball_test_report_${randomUUID().replaceAll('-', '')}` }); + try { + const analytics = await createAnalyticsStore(store.db), a = await store.createGuest(), b = await store.createGuest(); + const now = new Date('2026-10-13T08:00:00Z'); + const meta = { device: 'mobile', browser: 'Chrome 141', source: 'social', invitation: false }; + const s1 = await analytics.touch(a.playerId, new Date('2026-10-11T14:00:00Z'), true, meta); + const s2 = await analytics.touch(a.playerId, new Date('2026-10-12T15:00:00Z'), true, meta); + const s3 = await analytics.touch(b.playerId, new Date('2026-10-12T15:00:00Z'), true, { ...meta, device: 'pc' }); + const base = { environment: 'production', schemaVersion: 1, appVersion: '0.1.0', source: 'client' as const }; + for (const [i, s] of [s1, s2, s3].entries()) await analytics.insert({ ...base, _id: `view-${i}`, playerId: s.playerId, sessionId: s.sessionId, occurredAt: s.startedAt, eventType: 'page_view' }); + for (const [i, s] of [s2, s3].entries()) { + await analytics.insert({ ...base, _id: `start-${i}`, playerId: s.playerId, sessionId: s.sessionId, eventType: 'game_started', matchId: 'shared-match', mode: 'friend', occurredAt: new Date('2026-10-13T00:00:00Z') }); + await analytics.insert({ ...base, _id: `finish-${i}`, playerId: s.playerId, sessionId: s.sessionId, eventType: 'game_finished', matchId: 'shared-match', mode: 'friend', outcome: i ? 'loss' : 'win', occurredAt: new Date('2026-10-13T00:01:00Z') }); + } + await analytics.insert({ ...base, _id: 'test-game', eventType: 'game_started', matchId: 'test', mode: 'bot', occurredAt: new Date('2026-10-13T00:00:00Z'), environment: 'test' }); + const filters = { start: new Date('2026-10-11T00:00:00Z'), end: now }; + const report = await getAnalyticsReport(store.db, filters, now); + assert.equal(report.overview.visitors, 2); assert.equal(report.overview.wau.visitors, 2); assert.equal(report.overview.mau.visitors, 2); + assert.equal(report.clientGameplay.players, 2); assert.equal(report.clientGameplay.startedMatches, 1); assert.equal(report.clientGameplay.resultReports, 2); + assert.equal(report.daily.find(d => d.day === '2026-10-13')!.matches, 1); + assert.equal(report.overview.dau.players, 2); + assert.ok(!('gameplay' in report)); assert.ok(!('sessionConversion' in report.overview)); + const filtered = await getAnalyticsReport(store.db, { ...filters, device: 'mobile' }, now); + assert.equal(filtered.overview.visitors, 1); assert.equal(filtered.clientGameplay.players, 1); + assert.equal(filtered.revisit.visitCohorts[0].size, 1); + const bot = await getAnalyticsReport(store.db, { ...filters, mode: 'bot' }, now); + assert.equal(bot.overview.visitors, 2); assert.equal(bot.clientGameplay.startedMatches, 0); + assert.equal((await store.db.listCollections({ name: 'matches' }).toArray()).length, 0); + } finally { await store.db.dropDatabase(); await store.close(); } +}); diff --git a/tests/resilience.test.ts b/tests/resilience.test.ts new file mode 100644 index 0000000..383a373 --- /dev/null +++ b/tests/resilience.test.ts @@ -0,0 +1,65 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { randomBytes, randomUUID } from 'node:crypto'; +import { createStatsStore } from '../src/server/stats-store.js'; +import { createGameSessionStore } from '../src/server/game-session-store.js'; +import { createResultReporter } from '../src/client/result-reporter.js'; +import { Analytics } from '../src/server/analytics.js'; +import { createAnalyticsStore } from '../src/server/analytics-store.js'; +import { ManualClock } from './helpers.js'; +import { RETRY_MAX_AGE_MS, RETRY_QUEUE_LIMIT } from '../constants/analytics.js'; +const uri = process.env.TEST_MONGODB_URI; +test('lost result response keeps registration locked until idempotent retry settles', { skip: !uri }, async () => { + const store = await createStatsStore({ mongoUri: uri!, databaseName: `dragon_ball_test_retry_${randomUUID().replaceAll('-', '')}` }); + const clock = new ManualClock(), sessions = await createGameSessionStore(store.db, clock.now); + const auth = await sessions.create(randomBytes(32).toString('base64url')); + const active = await sessions.register(auth, { requestId: randomUUID(), matchId: randomUUID(), mode: 'bot', expectedPreviousSeq: 0 }); + const values = new Map(); let requests = 0; + const reporter = createResultReporter({ gameSessionId: auth.gameSessionId, record: async data => { + if (++requests === 1) throw new Error('offline'); + const ack = await sessions.record(auth, data); + if (requests === 2) throw new Error('response lost'); + return ack; + } }, { getItem: k => values.get(k) ?? null, setItem: (k, v) => { values.set(k, v); }, removeItem: k => { values.delete(k); } }, clock); + try { + const pending = reporter.submit({ ...active, outcome: 'win', reason: 'hit' }); + await assert.rejects(sessions.register(auth, { requestId: randomUUID(), matchId: randomUUID(), mode: 'bot', expectedPreviousSeq: 1 }), /MATCH_PENDING/); + await clock.advance(10000); const ack = await pending; + assert.equal(ack.status, 'duplicate'); assert.equal(requests, 3); assert.equal(values.size, 0); + assert.equal((await sessions.get(auth)).stats.bot.current, 1); + assert.equal((await sessions.get(auth)).stats.bot.wins, 1); + } finally { reporter.destroy(); await store.db.dropDatabase(); await store.close(); } +}); +test('analytics outage is bounded, retry preserves receive time and game result persistence continues', { skip: !uri }, async () => { + const databaseName = `dragon_ball_test_outage_${randomUUID().replaceAll('-', '')}`; + const store = await createStatsStore({ mongoUri: uri!, databaseName }); const clock = new ManualClock(); + const aStore = await createAnalyticsStore(store.db); const original = aStore.insert.bind(aStore); let offline = true; + const analytics = new Analytics({ ...aStore, insert: e => offline ? Promise.reject(new Error('offline')) : original(e) }, clock); + const guest = await store.createGuest(), player = { playerId: guest.playerId }; + const sessions = await createGameSessionStore(store.db, clock.now); + const auth = await sessions.create(randomBytes(32).toString('base64url'), guest.playerId); + try { + const at = clock.now(); await assert.rejects(analytics.clientEvent(player, { eventId: 'page-1', eventType: 'page_view', screen: 'home' })); + const active = await sessions.register(auth, { requestId: randomUUID(), matchId: randomUUID(), mode: 'bot', expectedPreviousSeq: 0 }); + await sessions.record(auth, { ...active, outcome: 'loss', reason: 'timeout' }); + assert.equal((await sessions.get(auth)).stats.bot.losses, 1); + assert.ok(analytics.status().incomplete); assert.equal(analytics.status().failures, 1); + offline = false; await clock.advance(5000); + const event = await store.db.collection('analytics_events').findOne(); assert.equal(+event!.occurredAt, at); + assert.equal(await store.db.collection('analytics_events').countDocuments(), 1); + assert.equal(analytics.status().queued, 0); + offline = true; + await assert.rejects(analytics.clientEvent(player, { eventId: 'page-2', eventType: 'page_view', screen: 'home' })); + const deferred: Promise[] = []; + for (let i = 1; i < RETRY_QUEUE_LIMIT; i++) deferred.push(analytics.enqueue(`pending-${i}`, clock.now(), async () => {}).catch(() => {})); + await assert.rejects(analytics.enqueue('overflow', clock.now(), async () => {})); + assert.equal(analytics.status().queued, 100); assert.ok(analytics.status().dropped > 0); + const retry = clock.timers.get(analytics.timer)!; clock.timers.delete(analytics.timer); + clock.time += RETRY_MAX_AGE_MS + 1; retry.callback(); + await Promise.all(deferred); + assert.equal(analytics.status().queued, 0); assert.ok(analytics.status().dropped >= 101); + analytics.windows.set(player.playerId, { at: clock.now(), count: 120 }); + await assert.rejects(analytics.clientEvent(player, { eventId: 'limited', eventType: 'page_view', screen: 'home' })); + assert.equal(analytics.status().rateLimited, 1); assert.equal(analytics.status().incomplete, true); + } finally { await analytics.close(); await store.db.dropDatabase(); await store.close(); } +}); diff --git a/tests/result-reporter.test.ts b/tests/result-reporter.test.ts new file mode 100644 index 0000000..2d1f29a --- /dev/null +++ b/tests/result-reporter.test.ts @@ -0,0 +1,40 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createResultReporter } from '../src/client/result-reporter.js'; +import { emptyPlayerStats, type ResultSubmission } from '../src/shared/p2p-types.js'; +import { PENDING_RESULT_KEY } from '../constants/recording.js'; + +function storage() { + const data = new Map(); + return { getItem: (key: string) => data.get(key) ?? null, setItem: (key: string, value: string) => { data.set(key, value); }, removeItem: (key: string) => { data.delete(key); } }; +} +const result: ResultSubmission = { matchId: 'm1', seq: 1, mode: 'friend', outcome: 'win', reason: 'hit' }; +test('lost result response retries identical payload and clears persisted result only after acknowledgement', async () => { + const store = storage(), sent: ResultSubmission[] = []; const timers: { fn: () => void; ms: number }[] = []; + const reporter = createResultReporter({ gameSessionId: 's1', record: async data => { + sent.push(structuredClone(data)); if (sent.length === 1) throw new Error('network'); + return { status: 'duplicate', stats: emptyPlayerStats() }; + } }, store, { setTimeout: (fn, ms) => { timers.push({ fn, ms }); return timers.length; }, clearTimeout: () => {} }); + const promise = reporter.submit(result); + await new Promise(r => setImmediate(r)); + assert.ok(store.getItem(PENDING_RESULT_KEY)); assert.equal(timers[0].ms, 2000); + timers.shift()!.fn(); + assert.equal((await promise).status, 'duplicate'); + assert.deepEqual(sent, [result, result]); assert.equal(store.getItem(PENDING_RESULT_KEY), null); + reporter.destroy(); +}); +test('reload resumes the pending result but a different session never submits it', async () => { + const store = storage(); store.setItem(PENDING_RESULT_KEY, JSON.stringify({ gameSessionId: 's1', result })); + const sent: ResultSubmission[] = []; + const wrong = createResultReporter({ gameSessionId: 's2', record: async data => { sent.push(data); return { status: 'applied', stats: emptyPlayerStats() }; } }, store); + await assert.rejects(wrong.resumePending(), /SESSION_MISMATCH/); assert.equal(sent.length, 0); wrong.destroy(); + const correct = createResultReporter({ gameSessionId: 's1', record: async data => { sent.push(data); return { status: 'applied', stats: emptyPlayerStats() }; } }, store); + assert.equal((await correct.resumePending())?.status, 'applied'); assert.deepEqual(sent, [result]); correct.destroy(); +}); +test('permanent rejection stops retries and a different result cannot replace an in-flight one', async () => { + const store = storage(); let count = 0; + const reporter = createResultReporter({ gameSessionId: 's1', record: async () => { count++; throw Object.assign(new Error('INVALID_SESSION'), { status: 401 }); } }, store); + const first = reporter.submit(result); + await assert.rejects(reporter.submit({ ...result, matchId: 'm2' }), /RESULT_PENDING/); + await assert.rejects(first, /INVALID_SESSION/); assert.equal(count, 1); assert.ok(store.getItem(PENDING_RESULT_KEY)); reporter.destroy(); +}); diff --git a/tests/rooms.test.ts b/tests/rooms.test.ts new file mode 100644 index 0000000..1765ad1 --- /dev/null +++ b/tests/rooms.test.ts @@ -0,0 +1,42 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { randomUUID } from 'node:crypto'; +import { MongoClient } from 'mongodb'; +import { createRoomStore } from '../src/server/rooms.js'; +import { WAITING_ROOM_TTL_MS } from '../constants/session.js'; + +test('DB rooms atomically reserve the last seat, expire before TTL deletion and rematch once', { skip: !process.env.TEST_MONGODB_URI }, async () => { + const client = await new MongoClient(process.env.TEST_MONGODB_URI!, { serverSelectionTimeoutMS: 5000 }).connect(); + const db = client.db(`dragon_ball_test_rooms_${randomUUID().replaceAll('-', '')}`); + try { + let now = Date.now(); const rooms = await createRoomStore(db, () => now); + const request = randomUUID(); const room = await rooms.create('host', request); + assert.deepEqual(await rooms.create('host', request), room); + await assert.rejects(rooms.join('host', room.code), /SELF_JOIN/); + const entries = await Promise.allSettled(['guestA', 'guestB'].map(id => rooms.join(id, room.code))); + assert.equal(entries.filter(r => r.status === 'fulfilled').length, 1); + const guest = entries[0].status === 'fulfilled' ? 'guestA' : 'guestB'; + const paired = await rooms.get(room.id, 'host'); assert.equal(paired.count, 2); + assert.equal((await rooms.join(guest, room.code)).currentMatchId, paired.currentMatchId); + await assert.rejects(rooms.get(room.id, 'stranger'), /ROOM_NOT_FOUND/); + const next = await Promise.all([rooms.rematch(room.id, 'host', 1), rooms.rematch(room.id, guest, 1), rooms.rematch(room.id, guest, 1)]); + const current = await rooms.get(room.id, 'host'); + assert.equal(current.round, 2); assert.notEqual(current.currentMatchId, room.currentMatchId); + for (const r of next.filter(r => r.round === 2)) assert.equal(r.currentMatchId, current.currentMatchId); + assert.equal((await rooms.rematch(room.id, 'host', 1)).currentMatchId, current.currentMatchId); + await rooms.leave(room.id, guest); assert.equal((await rooms.get(room.id, 'host')).status, 'closed'); + const waiting = await rooms.create('host', randomUUID()); now += WAITING_ROOM_TTL_MS; + assert.ok(await db.collection('rooms').findOne({ _id: waiting.id })); + await assert.rejects(rooms.join('new', waiting.code), /ROOM_EXPIRED/); + const fresh = await rooms.create('host', randomUUID()); assert.notEqual(fresh.id, waiting.id); + } finally { try { await db.dropDatabase(); } finally { await client.close(); } } +}); +test('one session cannot occupy two open rooms even with concurrent creates', { skip: !process.env.TEST_MONGODB_URI }, async () => { + const client = await new MongoClient(process.env.TEST_MONGODB_URI!, { serverSelectionTimeoutMS: 5000 }).connect(); + const db = client.db(`dragon_ball_test_roomrace_${randomUUID().replaceAll('-', '')}`); + try { + const rooms = await createRoomStore(db); + const results = await Promise.allSettled([rooms.create('host', randomUUID()), rooms.create('host', randomUUID())]); + assert.equal(results.filter(r => r.status === 'fulfilled').length, 1); + } finally { try { await db.dropDatabase(); } finally { await client.close(); } } +}); diff --git a/tests/rules.test.ts b/tests/rules.test.ts new file mode 100644 index 0000000..1c515f4 --- /dev/null +++ b/tests/rules.test.ts @@ -0,0 +1,35 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { isLegalAction, resolveTurn } from '../src/shared/rules.js'; + +const actions = ['charge', 'blast', 'guard', 'beam', 'dodge'] as const; +const winners = [ + [null, 1, null, 1, null], + [0, null, null, 1, null], + [null, null, null, 1, null], + [0, 0, 0, null, null], + [null, null, null, null, null], +]; +for (let a = 0; a < 5; a++) for (let b = 0; b < 5; b++) { + test(`${actions[a]} / ${actions[b]} follows the agreed outcome table`, () => { + const result = resolveTurn([3, 3], [actions[a], actions[b]]) as { winnerIndex: number | null }; + assert.equal(result.winnerIndex, winners[a][b]); + }); +} +test('first turn allows only charge and guard; unknown actions are rejected', () => { + assert.equal(isLegalAction('charge', 0), true); + assert.equal(isLegalAction('guard', 0), true); + for (const action of ['blast', 'beam', 'dodge', 'fake']) assert.equal(isLegalAction(action, 0), false); +}); +test('energy is spent on evasion and clashing attacks, and a hit cancels charging', () => { + assert.deepEqual(resolveTurn([3, 1], ['beam', 'dodge']), { energy: [0, 0], winnerIndex: null, hitIndex: null }); + assert.deepEqual(resolveTurn([1, 0], ['blast', 'charge']), { energy: [0, 0], winnerIndex: 0, hitIndex: 1 }); + assert.deepEqual(resolveTurn([1, 1], ['blast', 'blast']), { energy: [0, 0], winnerIndex: null, hitIndex: null }); + assert.deepEqual(resolveTurn([0, 0], ['charge', 'charge']), { energy: [1, 1], winnerIndex: null, hitIndex: null }); + assert.deepEqual(resolveTurn([0, 1], ['guard', 'dodge']), { energy: [0, 0], winnerIndex: null, hitIndex: null }); +}); +test('illegal actions cannot spend energy or mutate the input', () => { + const energy: [number, number] = [0, 0]; + assert.throws(() => resolveTurn(energy, ['blast', 'charge'])); + assert.deepEqual(energy, [0, 0]); +}); diff --git a/tests/server.test.ts b/tests/server.test.ts new file mode 100644 index 0000000..b02c21a --- /dev/null +++ b/tests/server.test.ts @@ -0,0 +1,39 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { randomBytes, randomUUID } from 'node:crypto'; +import { MongoClient } from 'mongodb'; +import { io } from 'socket.io-client'; +import { createGameServer } from '../src/server/create-server.js'; +import { ManualClock } from './helpers.js'; +const uri = process.env.TEST_MONGODB_URI; + +test('HTTP game registration survives server restart without game timers and old gameplay sockets are rejected', { skip: !uri }, async () => { + const databaseName = 'dragon_ball_test_server_' + randomUUID().replaceAll('-', ''), clock = new ManualClock(); + const options = { mongoUri: uri!, databaseName, clientDirectory: 'dist/client', clock }; + let app = await createGameServer(options); + const client = await new MongoClient(uri!).connect(); + let socket: ReturnType | undefined; + try { + const url = await app.listen({ host: '127.0.0.1', port: 0 }); + assert.equal((await app.inject('/health')).statusCode, 200); + const session = (await app.inject({ method: 'POST', url: '/api/game-sessions', payload: { createRequestId: randomBytes(32).toString('base64url') } })).json().data; + const headers = { authorization: 'Bearer ' + session.token, 'x-game-session': session.gameSessionId }; + const input = { requestId: 'register', matchId: 'bot-match', mode: 'bot', expectedPreviousSeq: 0 }; + const match = (await app.inject({ method: 'POST', url: '/api/game-session/matches', headers, payload: input })).json().data; + assert.equal(match.seq, 1); + await clock.advance(12000); + assert.equal((await app.inject({ url: '/api/game-session', headers })).json().data.activeMatch.matchId, 'bot-match'); + assert.equal(await client.db(databaseName).collection('matches').countDocuments(), 0); + socket = io(url, { autoConnect: false, reconnection: false, transports: ['websocket'] }); + const status = new Promise(resolve => { socket!.once('connect_error', () => resolve('rejected')); socket!.once('connect', () => resolve('accepted')); }); + socket.connect(); assert.equal(await status, 'rejected'); socket.disconnect(); + await app.close(); app = await createGameServer(options); + assert.equal((await app.inject({ url: '/api/game-session', headers })).json().data.activeMatch.matchId, 'bot-match'); + const payload = { ...match, outcome: 'loss', reason: 'timeout' }; + assert.equal((await app.inject({ method: 'POST', url: '/api/game-session/results', headers, payload })).json().data.stats.bot.losses, 1); + assert.equal((await app.inject({ method: 'POST', url: '/api/game-session/results', headers, payload })).json().data.status, 'duplicate'); + assert.equal((await app.inject({ method: 'POST', url: '/api/game-session/results', payload })).statusCode, 401); + const state = (await app.inject({ url: '/api/game-session', headers })).json().data; + assert.equal(state.activeMatch, undefined); assert.equal(state.stats.bot.losses, 1); + } finally { socket?.disconnect(); await app.close(); try { await client.db(databaseName).dropDatabase(); } finally { await client.close(); } } +}); diff --git a/tests/signaling.test.ts b/tests/signaling.test.ts new file mode 100644 index 0000000..016f7f5 --- /dev/null +++ b/tests/signaling.test.ts @@ -0,0 +1,51 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { randomBytes, randomUUID, createHmac } from 'node:crypto'; +import { createServer } from 'node:http'; +import { Server } from 'socket.io'; +import { io as connect, type Socket } from 'socket.io-client'; +import { MongoClient } from 'mongodb'; +import { createGameSessionStore } from '../src/server/game-session-store.js'; +import { createRoomStore } from '../src/server/rooms.js'; +import { registerSignaling } from '../src/server/signaling.js'; + +test('signaling authenticates membership, relays only within room and releases sockets without forfeiting', { skip: !process.env.TEST_MONGODB_URI }, async () => { + const client = await new MongoClient(process.env.TEST_MONGODB_URI!, { serverSelectionTimeoutMS: 5000 }).connect(); + const db = client.db(`dragon_ball_test_signal_${randomUUID().replaceAll('-', '')}`); + const http = createServer(), io = new Server(http); const sockets: Socket[] = []; + try { + const sessions = await createGameSessionStore(db), rooms = await createRoomStore(db); + const a = await sessions.create(randomBytes(32).toString('base64url')), b = await sessions.create(randomBytes(32).toString('base64url')), outsider = await sessions.create(randomBytes(32).toString('base64url')); + const room = await rooms.create(a.gameSessionId, randomUUID()); await rooms.join(b.gameSessionId, room.code); + const signaling = registerSignaling(io, sessions, rooms, { turnUrls: ['turn:localhost:3478'], turnSecret: 'test-secret' }); + await new Promise(r => http.listen(0, '127.0.0.1', r)); + const url = `http://127.0.0.1:${(http.address() as any).port}/signal`; + function socket(auth: typeof a) { const s = connect(url, { transports: ['websocket'], reconnection: false, autoConnect: false, auth: { ...auth, roomId: room.id } }); sockets.push(s); return s; } + const sa = socket(a), sb = socket(b), stranger = socket(outsider); + const ready = (s: Socket) => new Promise((r, reject) => { s.once('connect', () => r()); s.once('connect_error', reject); s.connect(); }); + await Promise.all([ready(sa), ready(sb)]); await assert.rejects(ready(stranger)); + const received = new Promise(r => sb.once('signal', r)); + const packet = { type: 'offer', matchId: room.currentMatchId, epoch: 0, payload: { type: 'offer', sdp: 'test-sdp' } }; + assert.equal((await sa.timeout(2000).emitWithAck('signal', packet)).ok, true); + assert.equal((await received).payload.sdp, 'test-sdp'); + assert.equal((await sa.timeout(2000).emitWithAck('signal', { ...packet, matchId: randomUUID() })).ok, false); + assert.equal((await sb.timeout(2000).emitWithAck('signal', packet)).ok, false); + const resumed = socket(a); await ready(resumed); await new Promise(r => setTimeout(r, 20)); + assert.equal(sa.connected, false); assert.equal(resumed.connected, true); + assert.equal(signaling.namespace.sockets.size, 2); + const resumedMessage = new Promise(r => sb.once('signal', r)); + assert.equal((await resumed.timeout(2000).emitWithAck('signal', { ...packet, epoch: 1 })).ok, true); + assert.equal((await resumedMessage).epoch, 1); + const active = await sessions.register(a, { requestId: randomUUID(), matchId: room.currentMatchId, roomId: room.id, mode: 'friend', expectedPreviousSeq: 0 }); + resumed.disconnect(); sb.disconnect(); await new Promise(r => setTimeout(r, 20)); + assert.equal(signaling.namespace.sockets.size, 0); + assert.equal((await sessions.get(a)).activeMatch?.matchId, active.matchId); + const rtc = await signaling.rtcConfig(a, room.id); + const turn = rtc.iceServers[0]; assert.ok(turn.username); + assert.equal(turn.credential, createHmac('sha1', 'test-secret').update(turn.username!).digest('base64')); + assert.ok(rtc.expiresAt > Date.now()); + } finally { + sockets.forEach(s => s.disconnect()); await new Promise(r => io.close(() => r())); + try { await db.dropDatabase(); } finally { await client.close(); } + } +}); diff --git a/tests/stats-store.test.ts b/tests/stats-store.test.ts new file mode 100644 index 0000000..3c5719c --- /dev/null +++ b/tests/stats-store.test.ts @@ -0,0 +1,20 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { randomUUID } from 'node:crypto'; +import { createStatsStore } from '../src/server/stats-store.js'; + +test('visitor identity persists without creating the removed match collection', { skip: !process.env.TEST_MONGODB_URI }, async () => { + const options = { mongoUri: process.env.TEST_MONGODB_URI!, databaseName: `dragon_ball_test_stats_${randomUUID().replaceAll('-', '')}` }; + let store = await createStatsStore(options); + try { + const guest = await store.createGuest('a'.repeat(43)); + assert.deepEqual(await store.createGuest('a'.repeat(43)), guest); + const player = await store.db.collection('players').findOne({ _id: guest.playerId as any }); + assert.ok(player?.guestTokenHash); + assert.ok(!JSON.stringify(player).includes(guest.guestToken)); + assert.equal(await store.findGuest('bad-token'), null); + await store.close(); store = await createStatsStore(options); + assert.deepEqual(await store.findGuest(guest.guestToken), guest); + assert.equal((await store.db.listCollections({ name: 'matches' }).toArray()).length, 0); + } finally { await store.db.dropDatabase(); await store.close(); } +}); diff --git a/tests/ui-scenarios.ts b/tests/ui-scenarios.ts new file mode 100644 index 0000000..740f601 --- /dev/null +++ b/tests/ui-scenarios.ts @@ -0,0 +1,80 @@ +import type { LocalMatchState } from '../src/shared/p2p-types.js'; +// Browser-only visual fixture. Uses the real view, rules and timings; never shipped. +import { createMatchView } from '../src/client/match-view.js'; +import { resolveTurn } from '../src/shared/rules.js'; +import { MATCH_COUNTDOWN_MS, TURN_DURATION_MS, REVEAL_DURATION_MS } from '../constants/gameplay.js'; +import type { ActionId, Pair, PlayerStats } from '../src/shared/types.js'; + +const container = document.querySelector('#match')!; +const zero = { current: 0, best: 0, wins: 0, losses: 0, draws: 0 }; +const stats: PlayerStats = { bot: { ...zero }, friend: { ...zero } }; +let current: LocalMatchState, timer: ReturnType | undefined; +let selfIndex: 0 | 1 = 0; +const view = createMatchView(container, async action => { + current = { ...current, ownAction: action, version: current.version + 1, clockNow: Date.now() }; + view.update(current); + return true; +}, () => prepare(), () => prepare(), () => { document.body.dataset.screen = 'result'; }); +function state(): LocalMatchState { + const now = Date.now(); + return { matchId: crypto.randomUUID(), mode: 'friend', phase: 'selecting', version: 1, selfIndex, energy: [3, 3], turnId: 1, clockNow: now, startsAt: now, deadline: now + TURN_DURATION_MS, ownAction: null, rematch: [false, false], opponentPresent: true, opponentLeft: false, saveStatus: 'saved', aborted: false }; +} +function show(s: LocalMatchState) { document.body.dataset.screen = 'match'; current = s; view.update(s); } +function prepare() { + clearTimeout(timer); + const s = state(); s.phase = 'countdown'; s.startsAt += MATCH_COUNTDOWN_MS; s.deadline = s.startsAt + TURN_DURATION_MS; + show(s); + timer = setTimeout(() => show({ ...current, phase: 'selecting', clockNow: Date.now(), version: current.version + 1 }), MATCH_COUNTDOWN_MS); +} +function reveal(actions: Pair, energy: Pair = [3, 3], elapsed = 0) { + clearTimeout(timer); + const s = state(), now = Date.now() - elapsed, outcome = resolveTurn(energy, actions); + s.energy = outcome.energy; s.phase = outcome.winnerIndex === null ? 'revealing' : 'finished'; + s.reveal = { ...outcome, matchId: s.matchId, turnId: 1, actions, energyBefore: energy, revealedAt: now, revealEndsAt: now + REVEAL_DURATION_MS }; + if (outcome.winnerIndex !== null) s.finished = { matchId: s.matchId, winnerIndex: outcome.winnerIndex, reason: 'hit', stats, revealEndsAt: now + REVEAL_DURATION_MS, saved: true }; + show(s); + if (!s.finished) timer = setTimeout(() => show({ ...current, phase: 'selecting', reveal: undefined, turnId: 2, ownAction: null, clockNow: Date.now(), deadline: Date.now() + TURN_DURATION_MS, version: current.version + 1 }), REVEAL_DURATION_MS); +} +function result(winnerIndex: 0 | 1 | null) { + clearTimeout(timer); + const s = state(); s.phase = 'finished'; + s.finished = { matchId: s.matchId, winnerIndex, reason: winnerIndex === null ? 'double_timeout' : 'timeout', stats, revealEndsAt: Date.now(), saved: true }; + show(s); +} +const scenarios: [string, () => void][] = [ + ['준비', prepare], ['선택', () => { clearTimeout(timer); show(state()); }], + ['충전', () => reveal(['charge', 'charge'])], ['파 명중', () => reveal(['blast', 'charge'])], + ['방어', () => reveal(['guard', 'blast'])], ['상쇄', () => reveal(['blast', 'blast'])], + ['순간이동', () => reveal(['dodge', 'beam'])], ['에네르기파', () => reveal(['beam', 'guard'])], + ['왼쪽 피격', () => reveal(['charge', 'blast'])], ['왼쪽 에네르기파 피격', () => reveal(['guard', 'beam'])], + ['에네르기파 상쇄', () => reveal(['beam', 'beam'])], ['파 회피', () => reveal(['blast', 'dodge'])], + ['양쪽 막기', () => reveal(['guard', 'guard'])], + ['피니시 복구', () => reveal(['blast', 'charge'], [3, 3], REVEAL_DURATION_MS + 500)], + ['컷씬 중간 복구', () => reveal(['beam', 'guard'], [3, 3], 650)], + ['에네르기파 표정 복구', () => reveal(['beam', 'guard'], [3, 3], 2500)], + ['에네르기파 소멸 복구', () => reveal(['beam', 'guard'], [3, 3], 3100)], + ['에네르기파 종료 뒤 복구', () => reveal(['guard', 'beam'], [3, 3], 60_000)], + ['컷씬 종료 뒤 복구', () => reveal(['charge', 'blast'], [3, 3], 60_000)], + ['컷씬 저장 응답', () => { + reveal(['blast', 'charge']); + timer = setTimeout(() => { + show({ ...current, version: current.version + 1, clockNow: Date.now() }); + timer = setTimeout(() => show({ ...current, version: current.version + 1, clockNow: Date.now() }), 4300); + }, 700); + }], + ['컷씬 중 새 대전', () => { + reveal(['blast', 'charge']); + timer = setTimeout(prepare, 900); + }], + ['패배', () => result(selfIndex === 0 ? 1 : 0)], ['승리', () => result(selfIndex)], ['무승부', () => result(null)], + ['기 부족', () => { clearTimeout(timer); show({ ...state(), energy: [0, 0] }); }], + ['오른쪽 플레이어', () => { selfIndex = selfIndex === 0 ? 1 : 0; clearTimeout(timer); show(state()); }], +]; +const picker = document.createElement('select'); picker.setAttribute('aria-label', '검증 상태'); +for (const [label] of scenarios) { const option = document.createElement('option'); option.textContent = label; option.value = label; picker.append(option); } +const replay = document.createElement('button'); replay.textContent = '재생'; +const run = () => scenarios.find(([label]) => label === picker.value)![1](); +picker.onchange = run; replay.onclick = run; +document.querySelector('#scenarios')!.append(picker, replay); +prepare(); + diff --git a/tests/webrtc-load-server.ts b/tests/webrtc-load-server.ts new file mode 100644 index 0000000..aa8c415 --- /dev/null +++ b/tests/webrtc-load-server.ts @@ -0,0 +1,25 @@ +// Isolated load fixture; never imported by the production server. +import { Collection } from 'mongodb'; +import { createGameServer } from '../src/server/create-server.js'; +const databaseName = process.env.LOAD_DATABASE!; +if (!/^dragon_ball_test_load_[a-f0-9]+$/.test(databaseName)) throw new Error('TEST_DATABASE_REQUIRED'); +const writes: Record = {}; +for (const name of ['insertOne', 'updateOne', 'updateMany', 'findOneAndUpdate'] as const) { + const original = Collection.prototype[name] as Function; + (Collection.prototype as any)[name] = function (...args: unknown[]) { + const key = `${this.collectionName}.${name}`; writes[key] = (writes[key] ?? 0) + 1; + return original.apply(this, args); + }; +} +const app = await createGameServer({ mongoUri: process.env.TEST_MONGODB_URI!, databaseName, clientDirectory: '.superpowers/missing-load-client', environment: 'test' }); +const sockets = new Set(); let peakSockets = 0; +app.server.on('upgrade', (_request, socket) => { sockets.add(socket); peakSockets = Math.max(peakSockets, sockets.size); socket.once('close', () => sockets.delete(socket)); }); +let previous = process.cpuUsage(), at = performance.now(); +const metrics = setInterval(() => { + const next = performance.now(), usage = process.cpuUsage(), delta = next - at; + process.send?.({ type: 'metrics', rss: process.memoryUsage().rss, cpuPercent: ((usage.user - previous.user) + (usage.system - previous.system)) / (delta * 1000) * 100, + sockets: sockets.size, peakSockets, writes }); + previous = usage; at = next; +}, 1000); +process.send?.({ type: 'ready', url: await app.listen({ port: 0, host: '127.0.0.1' }) }); +process.on('message', message => { if (message === 'stop') void (async () => { clearInterval(metrics); await app.close(); process.exit(0); })(); }); diff --git a/tests/webrtc-load.ts b/tests/webrtc-load.ts new file mode 100644 index 0000000..ff3421f --- /dev/null +++ b/tests/webrtc-load.ts @@ -0,0 +1,79 @@ +// HTTP/DB and transient signaling only: this does NOT simulate real RTC pairs. +import { fork } from 'node:child_process'; +import { randomBytes, randomUUID } from 'node:crypto'; +import { mkdir, writeFile } from 'node:fs/promises'; +import { MongoClient } from 'mongodb'; +import { io } from 'socket.io-client'; +const pairCount = 1500, seconds = Number(process.env.LOAD_SECONDS ?? 900); +const databaseName = `dragon_ball_test_load_${randomUUID().replaceAll('-', '')}`; +const child = fork(new URL('./webrtc-load-server.ts', import.meta.url), { env: { ...process.env, LOAD_DATABASE: databaseName }, stdio: ['ignore', 'inherit', 'inherit', 'ipc'], windowsHide: true }); +let url = '', phase = 'ramp', signalingConnections = 0, failures = 0; +const metrics: any[] = [], requests: Record }> = {}; +child.on('message', (message: any) => { if (message.type === 'metrics') metrics.push({ ...message, phase, at: Date.now() }); }); +await new Promise((resolve, reject) => { child.on('message', (m: any) => { if (m.type === 'ready') { url = m.url; resolve(); } }); child.once('error', reject); child.once('exit', code => reject(new Error(`SERVER_EXIT_${code}`))); }); +const sleep = (ms: number) => new Promise(r => setTimeout(r, Math.max(0, ms))); +async function post(path: string, data: unknown, session?: any) { + const start = performance.now(); let status = 0; + try { + const response = await fetch(url + path, { method: 'POST', headers: { 'content-type': 'application/json', ...(session ? { authorization: `Bearer ${session.token}`, 'x-game-session': session.gameSessionId } : {}) }, body: JSON.stringify(data), signal: AbortSignal.timeout(10000) }); + status = response.status; const body: any = await response.json(); + if (!response.ok || !body.ok) throw new Error(`HTTP_${status}_${body.code}`); + return body.data; + } finally { + const key = phase + ':' + path.replace(/\/rooms\/[^/]+\//, '/rooms/:id/'); + const entry = requests[key] ??= { ms: [], status: {} }; entry.ms.push(performance.now() - start); entry.status[status] = (entry.status[status] ?? 0) + 1; + } +} +async function signal(session: any, room: any) { + const socket = io(url + '/signal', { transports: ['websocket'], forceNew: true, reconnection: false, auth: { gameSessionId: session.gameSessionId, token: session.token, roomId: room.id } }); + try { await new Promise((resolve, reject) => { socket.once('connect', () => resolve()); socket.once('connect_error', reject); }); signalingConnections++; } + finally { socket.disconnect(); } +} +const pairs: { sessions: any[]; room: any; active: any[]; seq: number }[] = []; +async function prepare(index: number) { + const sessions = await Promise.all([0, 1].map(() => post('/api/game-sessions', { createRequestId: randomBytes(32).toString('base64url') }))); + let room = await post('/api/rooms', { requestId: randomUUID() }, sessions[0]); + room = await post('/api/rooms/join', { code: room.code }, sessions[1]); + await Promise.all(sessions.map(s => signal(s, room))); + const active = await Promise.all(sessions.map(s => post('/api/game-session/matches', { requestId: randomUUID(), matchId: room.currentMatchId, roomId: room.id, mode: 'friend', expectedPreviousSeq: 0 }, s))); + pairs[index] = { sessions, room, active, seq: 0 }; +} +const inflight = new Set>(); +function launch(work: Promise) { const tracked = work.catch(error => { failures++; console.error(String(error)); }).finally(() => inflight.delete(tracked)); inflight.add(tracked); } +const started = Date.now(); +try { + for (let i = 0; i < pairCount; i++) { await sleep(started + i * 40 - Date.now()); launch(prepare(i)); } + await Promise.all(inflight); if (pairs.filter(Boolean).length !== pairCount) throw new Error('RAMP_FAILED'); + const rampMs = Date.now() - started; console.log(JSON.stringify({ phase: 'ramp-complete', sessions: pairCount * 2, signalingConnections, rampMs })); + phase = 'steady'; const steadyStart = Date.now(); let duplicateResults = 0; + async function cycle(index: number) { + const p = pairs[index % pairCount]; + const results = p.active.map((active, i) => ({ ...active, outcome: i === 0 ? 'win' : 'loss', reason: 'hit' })); + await Promise.all(p.sessions.flatMap((s, i) => [post('/api/game-session/results', results[i], s), post('/api/analytics/activity', { visible: true }, s)])); + if (index % 10 === 0) { await Promise.all(p.sessions.map((s, i) => post('/api/game-session/results', results[i], s))); duplicateResults += 2; } + const round = p.room.round; + const rooms = await Promise.all(p.sessions.map(s => post('/api/rooms/' + p.room.id + '/rematch', { round, requestId: randomUUID() }, s))); + p.room = rooms.sort((a, b) => b.round - a.round)[0]; p.seq++; + p.active = await Promise.all(p.sessions.map(s => post('/api/game-session/matches', { requestId: randomUUID(), matchId: p.room.currentMatchId, roomId: p.room.id, mode: 'friend', expectedPreviousSeq: p.seq }, s))); + } + for (let i = 0; i < seconds * 25; i++) { + await sleep(steadyStart + i * 40 - Date.now()); launch(cycle(i)); + if (i && i % 1500 === 0) console.log(JSON.stringify({ phase, elapsedSeconds: Math.round((Date.now() - steadyStart) / 1000), completedPairCycles: i, inflight: inflight.size, failures })); + } + await Promise.all(inflight); + const elapsedMs = Date.now() - steadyStart; + const inspect = await new MongoClient(process.env.TEST_MONGODB_URI!).connect(); + try { + const db = inspect.db(databaseName); + const totals = await db.collection('game_sessions').aggregate([{ $group: { _id: null, sessions: { $sum: 1 }, wins: { $sum: '$stats.friend.wins' }, losses: { $sum: '$stats.friend.losses' }, best: { $max: '$stats.friend.best' } } }]).toArray(); + const percent = (values: number[], n: number) => values.sort((a,b) => a-b)[Math.min(values.length-1, Math.floor(values.length*n))] ?? null; + const summary = Object.fromEntries(Object.entries(requests).map(([key,v]) => [key, { count: v.ms.length, status: v.status, p50: percent(v.ms,.5), p95: percent(v.ms,.95), p99: percent(v.ms,.99) }])); + const steady = metrics.filter(m => m.phase === 'steady'); + const output = { scope: 'HTTP + Standalone MongoDB + transient signaling; no real RTC load or TURN', sessions: 3000, pairs: pairCount, requestedSeconds: seconds, elapsedMs, rampMs, signalingConnections, duplicateResults, failures, totals, legacyMatches: await db.collection('matches').countDocuments(), + resources: { peakRssBytes: Math.max(...steady.map(m => m.rss)), meanCpuPercentOneCore: steady.reduce((n,m) => n+m.cpuPercent,0)/steady.length, peakWebSockets: Math.max(...metrics.map(m=>m.peakSockets)), steadyPeakWebSockets: Math.max(...steady.map(m=>m.sockets)), writeMethodCalls: metrics.at(-1)?.writes }, summary, metrics }; + await mkdir('docs/verification', { recursive: true }); await writeFile('docs/verification/webrtc-load.json', JSON.stringify(output, null, 2)); + console.log(JSON.stringify({ ...output, metrics: undefined })); + // This invocation exclusively owns this randomly named test database. + await db.dropDatabase(); + } finally { await inspect.close(); } +} finally { child.send('stop'); } diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..e3a1aba --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "lib": ["ES2022", "DOM"], + "types": ["node"] + }, + "include": ["constants", "src", "tests", "vite.config.ts"] +} diff --git a/tsconfig.server.json b/tsconfig.server.json new file mode 100644 index 0000000..d0fd3ad --- /dev/null +++ b/tsconfig.server.json @@ -0,0 +1,5 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { "rootDir": ".", "outDir": "dist/server" }, + "include": ["constants", "src/shared", "src/server"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..7fd9e36 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vite'; +import { resolve } from 'node:path'; +export default defineConfig({ + root: 'src/client', + build: { outDir: '../../dist/client', emptyOutDir: true, + rollupOptions: { input: { game: resolve('src/client/index.html'), admin: resolve('src/client/admin/analytics/index.html') } }, + }, +});