From 4f24129e298f986c095a5f3fc9b62befbe9f7fe1 Mon Sep 17 00:00:00 2001 From: Horoli Date: Tue, 13 Oct 2026 11:15:26 +0900 Subject: [PATCH] Initial commit: ssessesse ki battle --- .env.example | 25 + .gitignore | 9 + AGENTS.md | 30 + README.md | 104 + constants/analytics.ts | 16 + constants/branding.ts | 1 + constants/cosmetics.ts | 40 + constants/cutscene.ts | 2 + constants/gameplay.ts | 14 + constants/home.ts | 8 + constants/legacy-data.ts | 4 + constants/ranked.ts | 10 + constants/recording.ts | 25 + constants/session.ts | 7 + constants/webrtc.ts | 10 + package-lock.json | 2522 +++++++++++++++++ package.json | 29 + scripts/cleanup-legacy-data.ts | 43 + src/client/action-renderer.ts | 59 + src/client/admin.css | 1 + src/client/admin.ts | 135 + src/client/admin/analytics/index.html | 6 + src/client/analytics.ts | 62 + src/client/api.ts | 69 + src/client/fighter-art.ts | 474 ++++ src/client/finish-cutscene.ts | 105 + src/client/fonts/BlackHanSans-Regular.ttf | Bin 0 -> 998424 bytes src/client/fonts/OFL.txt | 93 + src/client/friend-match.ts | 202 ++ src/client/game.css | 170 ++ src/client/home.css | 177 ++ src/client/index.html | 26 + src/client/lobby-features.css | 65 + src/client/lobby-features.ts | 239 ++ src/client/main.ts | 290 ++ src/client/match-controller.ts | 90 + src/client/match-view.ts | 142 + src/client/match.css | 245 ++ src/client/peer.ts | 165 ++ src/client/public/assets/BlackHanSans-OFL.txt | 93 + src/client/result-reporter.ts | 56 + src/client/styles.css | 4 + src/client/ui-theme.css | 165 ++ src/server/admin.ts | 51 + src/server/analytics-report.ts | 141 + src/server/analytics-store.ts | 64 + src/server/analytics.ts | 148 + src/server/clock.ts | 6 + src/server/create-server.ts | 54 + src/server/errors.ts | 4 + src/server/game-api.ts | 94 + src/server/game-session-store.ts | 122 + src/server/index.ts | 10 + src/server/ranked-api.ts | 38 + src/server/ranked-service.ts | 319 +++ src/server/rooms.ts | 105 + src/server/signaling.ts | 74 + src/server/stats-store.ts | 39 + src/shared/analytics-types.ts | 15 + src/shared/bot.ts | 8 + src/shared/cosmetics.ts | 35 + src/shared/match-engine.ts | 90 + src/shared/p2p-types.ts | 55 + src/shared/peer-protocol.ts | 62 + src/shared/ranked-types.ts | 26 + src/shared/rules.ts | 16 + src/shared/types.ts | 28 + tests/admin.test.ts | 46 + tests/analytics.test.ts | 53 + tests/bot.test.ts | 11 + tests/client-analytics.test.ts | 29 + tests/cosmetics.test.ts | 220 ++ tests/equipment-art.test.ts | 314 ++ tests/equipment-sync.test.ts | 36 + tests/game-api.test.ts | 80 + tests/game-records.test.ts | 61 + tests/helpers.ts | 18 + tests/http-analytics.test.ts | 70 + tests/legacy-cleanup.test.ts | 42 + tests/lobby-profile.test.ts | 135 + tests/main-recovery.test.ts | 63 + tests/match-controller.test.ts | 41 + tests/match-engine.test.ts | 88 + tests/p2p-recovery.test.ts | 132 + tests/peer-browser-monitor.js | 46 + tests/peer-protocol.test.ts | 39 + tests/peer.test.ts | 120 + tests/preview-server.ts | 50 + tests/ranked-mode-race.test.ts | 103 + tests/ranked.test.ts | 547 ++++ tests/report.test.ts | 38 + tests/resilience.test.ts | 65 + tests/result-reporter.test.ts | 40 + tests/rooms.test.ts | 42 + tests/rules.test.ts | 35 + tests/server.test.ts | 39 + tests/signaling.test.ts | 51 + tests/stats-store.test.ts | 20 + tests/ui-scenarios.ts | 80 + tests/webrtc-load-server.ts | 25 + tests/webrtc-load.ts | 79 + tsconfig.json | 13 + tsconfig.server.json | 5 + vite.config.ts | 8 + 104 files changed, 10520 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 README.md create mode 100644 constants/analytics.ts create mode 100644 constants/branding.ts create mode 100644 constants/cosmetics.ts create mode 100644 constants/cutscene.ts create mode 100644 constants/gameplay.ts create mode 100644 constants/home.ts create mode 100644 constants/legacy-data.ts create mode 100644 constants/ranked.ts create mode 100644 constants/recording.ts create mode 100644 constants/session.ts create mode 100644 constants/webrtc.ts create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 scripts/cleanup-legacy-data.ts create mode 100644 src/client/action-renderer.ts create mode 100644 src/client/admin.css create mode 100644 src/client/admin.ts create mode 100644 src/client/admin/analytics/index.html create mode 100644 src/client/analytics.ts create mode 100644 src/client/api.ts create mode 100644 src/client/fighter-art.ts create mode 100644 src/client/finish-cutscene.ts create mode 100644 src/client/fonts/BlackHanSans-Regular.ttf create mode 100644 src/client/fonts/OFL.txt create mode 100644 src/client/friend-match.ts create mode 100644 src/client/game.css create mode 100644 src/client/home.css create mode 100644 src/client/index.html create mode 100644 src/client/lobby-features.css create mode 100644 src/client/lobby-features.ts create mode 100644 src/client/main.ts create mode 100644 src/client/match-controller.ts create mode 100644 src/client/match-view.ts create mode 100644 src/client/match.css create mode 100644 src/client/peer.ts create mode 100644 src/client/public/assets/BlackHanSans-OFL.txt create mode 100644 src/client/result-reporter.ts create mode 100644 src/client/styles.css create mode 100644 src/client/ui-theme.css create mode 100644 src/server/admin.ts create mode 100644 src/server/analytics-report.ts create mode 100644 src/server/analytics-store.ts create mode 100644 src/server/analytics.ts create mode 100644 src/server/clock.ts create mode 100644 src/server/create-server.ts create mode 100644 src/server/errors.ts create mode 100644 src/server/game-api.ts create mode 100644 src/server/game-session-store.ts create mode 100644 src/server/index.ts create mode 100644 src/server/ranked-api.ts create mode 100644 src/server/ranked-service.ts create mode 100644 src/server/rooms.ts create mode 100644 src/server/signaling.ts create mode 100644 src/server/stats-store.ts create mode 100644 src/shared/analytics-types.ts create mode 100644 src/shared/bot.ts create mode 100644 src/shared/cosmetics.ts create mode 100644 src/shared/match-engine.ts create mode 100644 src/shared/p2p-types.ts create mode 100644 src/shared/peer-protocol.ts create mode 100644 src/shared/ranked-types.ts create mode 100644 src/shared/rules.ts create mode 100644 src/shared/types.ts create mode 100644 tests/admin.test.ts create mode 100644 tests/analytics.test.ts create mode 100644 tests/bot.test.ts create mode 100644 tests/client-analytics.test.ts create mode 100644 tests/cosmetics.test.ts create mode 100644 tests/equipment-art.test.ts create mode 100644 tests/equipment-sync.test.ts create mode 100644 tests/game-api.test.ts create mode 100644 tests/game-records.test.ts create mode 100644 tests/helpers.ts create mode 100644 tests/http-analytics.test.ts create mode 100644 tests/legacy-cleanup.test.ts create mode 100644 tests/lobby-profile.test.ts create mode 100644 tests/main-recovery.test.ts create mode 100644 tests/match-controller.test.ts create mode 100644 tests/match-engine.test.ts create mode 100644 tests/p2p-recovery.test.ts create mode 100644 tests/peer-browser-monitor.js create mode 100644 tests/peer-protocol.test.ts create mode 100644 tests/peer.test.ts create mode 100644 tests/preview-server.ts create mode 100644 tests/ranked-mode-race.test.ts create mode 100644 tests/ranked.test.ts create mode 100644 tests/report.test.ts create mode 100644 tests/resilience.test.ts create mode 100644 tests/result-reporter.test.ts create mode 100644 tests/rooms.test.ts create mode 100644 tests/rules.test.ts create mode 100644 tests/server.test.ts create mode 100644 tests/signaling.test.ts create mode 100644 tests/stats-store.test.ts create mode 100644 tests/ui-scenarios.ts create mode 100644 tests/webrtc-load-server.ts create mode 100644 tests/webrtc-load.ts create mode 100644 tsconfig.json create mode 100644 tsconfig.server.json create mode 100644 vite.config.ts 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 0000000000000000000000000000000000000000..e0d7584294610df5ca721096e03a5c0b1dd2894e GIT binary patch literal 998424 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%0AD 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%0AYABGcV3jf>Q*!= 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 +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') } }, + }, +});