Initial commit: ssessesse ki battle
This commit is contained in:
@@ -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=
|
||||
@@ -0,0 +1,9 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
.work/
|
||||
.tmp/
|
||||
.superpowers/
|
||||
*.log
|
||||
@@ -0,0 +1,30 @@
|
||||
# 프로젝트 작업 규칙
|
||||
|
||||
## UI 문구 최소화
|
||||
|
||||
화면 문구는 사용자의 행동과 판단에 필요한 정보만 표시한다.
|
||||
|
||||
### 허용
|
||||
- 버튼·메뉴·입력 항목의 기능을 식별하는 이름
|
||||
- 스킬·아이템의 실제 효과, 비용, 지속시간, 사용 조건
|
||||
- 현재 상태, 작업 결과, 오류와 해결 방법
|
||||
- 조작에 반드시 필요한 안내
|
||||
|
||||
### 금지
|
||||
- 기능을 소개하거나 장점을 홍보하는 문구
|
||||
- 제목·버튼·화면에서 이미 알 수 있는 내용을 반복하는 설명
|
||||
- 분위기를 채우기 위한 부제, 슬로건, 환영 문구
|
||||
- 구현 방식, 기술 스택, 프로토타입 제작 과정에 관한 설명
|
||||
- 요청하지 않은 도움말, 팁, 예시 문구
|
||||
|
||||
### 작성 기준
|
||||
- 문구를 삭제해도 사용자가 기능과 결과를 이해할 수 있으면 삭제한다.
|
||||
- 버튼은 행동 이름으로 작성한다. 예: "저장", "장착", "전투 시작".
|
||||
- 효과는 실제 동작을 짧고 정확하게 표현한다. 예: "강력한 공격으로 적을 압도합니다" 대신 "적 1명에게 피해 120".
|
||||
- 예시 수치를 그대로 사용하지 말고, 실제 구현에 맞는 수치와 조건만 표시한다. 수치와 조건을 임의로 만들지 않는다.
|
||||
- 상세 설명이 꼭 필요하면 해당 조작 근처나 도움말에 배치한다.
|
||||
- 접근성에 필요한 이름과 레이블은 유지한다.
|
||||
|
||||
### 완료 전 검토
|
||||
- 새로 추가하거나 수정한 모든 화면 문구를 검토한다.
|
||||
- 행동·효과·조건·상태·오류 이해에 기여하지 않는 문구를 제거한다.
|
||||
@@ -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 비용·실제 모바일 네트워크 검증은 별도 환경에서 확인한다.
|
||||
@@ -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;
|
||||
@@ -0,0 +1 @@
|
||||
export const GAME_NAME = '쎄쎄쎄:기공대전';
|
||||
@@ -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 } },
|
||||
];
|
||||
|
||||
@@ -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;
|
||||
@@ -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';
|
||||
@@ -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'],
|
||||
};
|
||||
@@ -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',
|
||||
];
|
||||
@@ -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;
|
||||
@@ -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<string, string> = {
|
||||
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: '랭킹 대전을 이용할 수 없습니다. 잠시 후 다시 시도하세요.',
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
Generated
+2522
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 <new-file>');
|
||||
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(); }
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { ACTIONS } from '../../constants/gameplay.js';
|
||||
import type { TurnReveal } from '../shared/types.js';
|
||||
|
||||
export function createActionRenderer(container: HTMLElement) {
|
||||
let last = '', timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const reset = () => {
|
||||
clearTimeout(timer); last = '';
|
||||
delete container.dataset.outcome;
|
||||
delete container.dataset.hitIndex;
|
||||
container.style.removeProperty('--impact-x');
|
||||
container.style.removeProperty('--reveal-duration');
|
||||
container.style.removeProperty('--elapsed');
|
||||
container.querySelectorAll<HTMLElement>('[data-fighter]').forEach(f => {
|
||||
delete f.dataset.action; delete f.dataset.hit; delete f.dataset.reaction;
|
||||
f.querySelector('.action-label')!.textContent = '';
|
||||
f.querySelector('.energy-change')!.textContent = '';
|
||||
});
|
||||
container.querySelector('.clash-label')!.textContent = '';
|
||||
};
|
||||
return {
|
||||
play(reveal: TurnReveal, serverNow: number) {
|
||||
const id = `${reveal.matchId}:${reveal.turnId}`;
|
||||
if (id === last || serverNow >= reveal.revealEndsAt) return;
|
||||
reset(); last = id;
|
||||
// Effects follow the authoritative reveal window, including reconnects.
|
||||
container.style.setProperty('--reveal-duration', `${reveal.revealEndsAt - reveal.revealedAt}ms`);
|
||||
container.style.setProperty('--elapsed', `${-Math.min(reveal.revealEndsAt - reveal.revealedAt, Math.max(0, serverNow - reveal.revealedAt))}ms`);
|
||||
const attacks = ['blast', 'beam'];
|
||||
const [a, b] = reveal.actions;
|
||||
const outcome = reveal.hitIndex !== null ? 'hit' : attacks.includes(a) && a === b ? 'clash'
|
||||
: ((a === 'guard' && b === 'blast') || (b === 'guard' && a === 'blast')) ? 'block'
|
||||
: ((a === 'dodge' && attacks.includes(b)) || (b === 'dodge' && attacks.includes(a))) ? 'dodge'
|
||||
: a === 'charge' || b === 'charge' ? 'charge' : 'safe';
|
||||
container.dataset.outcome = outcome;
|
||||
if (reveal.hitIndex !== null) {
|
||||
container.dataset.hitIndex = String(reveal.hitIndex);
|
||||
container.style.setProperty('--impact-x', reveal.hitIndex === 0 ? '27%' : '73%');
|
||||
}
|
||||
container.querySelectorAll<HTMLElement>('[data-fighter]').forEach((fighter, i) => {
|
||||
fighter.dataset.action = reveal.actions[i]; fighter.dataset.hit = String(reveal.hitIndex === i);
|
||||
if (attacks.includes(reveal.actions[i])) {
|
||||
const source = fighter.querySelector('.avatar')!.getBoundingClientRect();
|
||||
const target = container.querySelector(`[data-fighter="${1 - i}"] .avatar`)!.getBoundingClientRect();
|
||||
const arena = container.getBoundingClientRect();
|
||||
const shotWidth = reveal.actions[i] === 'beam' ? .75 : .44;
|
||||
const start = source.left + source.width * (i === 0 ? .78 + shotWidth / 2 : .22 - shotWidth / 2);
|
||||
const contact = outcome === 'clash' ? arena.left + arena.width / 2
|
||||
: target.left + target.width * (outcome === 'block' ? i === 0 ? .25 : .75 : .5);
|
||||
fighter.style.setProperty('--shot-distance', `${contact - start}px`);
|
||||
}
|
||||
fighter.querySelector('.action-label')!.textContent = ACTIONS[reveal.actions[i]].name;
|
||||
const delta = reveal.energy[i] - reveal.energyBefore[i];
|
||||
fighter.querySelector('.energy-change')!.textContent = fighter.classList.contains('self') && delta ? `${delta > 0 ? '+' : ''}${delta}` : '';
|
||||
});
|
||||
container.querySelector('.clash-label')!.textContent = ({ hit: '명중!', clash: '상쇄', block: '방어!', dodge: '회피!', charge: '기 충전', safe: '' })[outcome];
|
||||
timer = setTimeout(reset, Math.max(0, reveal.revealEndsAt - serverNow));
|
||||
}, reset, destroy: reset,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
.admin-app{max-width:1400px;margin:auto;padding:0 32px}.admin-app header{height:90px}.admin-app h1{font-size:23px;letter-spacing:-.6px}.admin-app header>span{font-size:12px;color:#647086}#filters{display:flex;flex-wrap:wrap;gap:12px;align-items:flex-end;margin:28px 0 16px}#filters label{display:flex;flex-direction:column;gap:7px;font-size:12px;font-weight:700}#filters input,#filters select{font-size:13px;max-width:205px;border-radius:10px}#filters input[name=source]{width:130px}.admin-meta{font-size:12px;color:#647086;line-height:1.8;margin:8px 0 14px}#error{color:#a42c20;font-size:13px;line-height:1.8}.admin-app nav{display:flex;gap:8px;padding:14px 0;border-bottom:1px solid var(--line);overflow:auto}.admin-app nav button{font-size:13px;min-height:42px;white-space:nowrap}.admin-app nav button[aria-pressed=true]{border-color:#172033;background:#172033;color:white}.admin-app main{display:block;padding:24px 0}.report-section,.user-detail:not(:empty){background:white;border:1px solid var(--line);border-radius:18px;padding:22px;margin-bottom:18px}.report-section h2,.user-detail h2{font-size:18px;margin:0 0 14px}.report-section h3,.user-detail h3{font-size:14px;margin:24px 0 10px}.metric-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:12px;margin:14px 0}.metric{background:#f6f7f9;padding:18px;border-radius:12px;min-width:0}.metric>span{display:block;font-size:12px;color:#647086;margin-bottom:9px}.metric>strong{display:block;font-size:27px;line-height:1.35;overflow-wrap:anywhere;font-variant-numeric:tabular-nums}.table-scroll{overflow:auto}table{border-collapse:collapse;width:100%;font-size:12px;white-space:nowrap;margin:12px 0}th,td{padding:12px 14px;text-align:left;border-bottom:1px solid #edf0f5;font-variant-numeric:tabular-nums}th{font-weight:700;color:#596477;background:#f9fafc}td:first-child,th:first-child{padding-left:10px}.chart{width:100%;max-height:240px;margin:14px 0}.chart text{font-size:11px;fill:#647086}.pager{display:flex;align-items:center;gap:18px;justify-content:center;margin-top:22px;font-size:13px}.pager button{font-size:13px}.user-detail .metric strong{font-size:16px}@media(max-width:700px){.admin-app{padding:0 16px}.admin-app header{height:auto;padding:20px 0;display:block}.admin-app header>span{display:block;margin-top:8px}#filters{gap:10px}#filters label{flex:1;min-width:140px}#filters input,#filters select{width:100%;max-width:none}#filters input[name=source]{width:100%}.report-section,.user-detail:not(:empty){padding:16px}.metric-grid{grid-template-columns:repeat(2,1fr)}.metric{padding:14px}.metric>strong{font-size:23px}.chart{min-width:500px}.report-section{overflow:hidden}}
|
||||
@@ -0,0 +1,135 @@
|
||||
import type { Ratio } from '../shared/analytics-types.js';
|
||||
import './styles.css';
|
||||
import './admin.css';
|
||||
const form = document.getElementById('filters')! as HTMLFormElement, content = document.getElementById('report')!;
|
||||
let report: any, tab = 'overview', detailId = '', detailPage = 1, reportLoadError = '';
|
||||
const number = (value: unknown) => typeof value === 'number' ? value.toLocaleString('ko-KR', { maximumFractionDigits: 1 }) : '데이터 없음';
|
||||
const rate = (r: Ratio | null | undefined) => r?.percent === null || !r ? '데이터 없음' : `${number(r.percent)}% (${number(r.numerator)}/${number(r.denominator)})`;
|
||||
const time = (value: unknown) => value ? new Date(String(value)).toLocaleString('ko-KR', { timeZone: 'Asia/Seoul', hour12: false }) : '데이터 없음';
|
||||
const short = (value: string) => value ? value.slice(0, 8) : '—';
|
||||
function node(tag: string, text?: string, className?: string) {
|
||||
const el = document.createElement(tag); if (text !== undefined) el.textContent = text; if (className) el.className = className; return el;
|
||||
}
|
||||
function section(title: string, note?: string) {
|
||||
const el = node('section', undefined, 'report-section'); el.append(node('h2', title)); if (note) el.append(node('p', note, 'admin-meta')); content.append(el); return el;
|
||||
}
|
||||
function table(parent: HTMLElement, headers: string[], rows: unknown[][]) {
|
||||
const wrap = node('div', undefined, 'table-scroll'), t = node('table'), head = node('tr');
|
||||
headers.forEach(h => { const cell = node('th', h); cell.setAttribute('scope', 'col'); head.append(cell); });
|
||||
const thead = node('thead'); thead.append(head); t.append(thead);
|
||||
const body = node('tbody');
|
||||
for (const row of rows) { const tr = node('tr'); for (const value of row) { const cell = node('td'); if (value instanceof Node) cell.append(value); else cell.textContent = String(value ?? '—'); tr.append(cell); } body.append(tr); }
|
||||
t.append(body); wrap.append(t); parent.append(wrap); if (!rows.length) parent.append(node('p', '데이터 없음', 'admin-meta'));
|
||||
}
|
||||
function cards(parent: HTMLElement, values: [string, unknown][]) {
|
||||
const grid = node('div', undefined, 'metric-grid');
|
||||
for (const [label, value] of values) { const card = node('div', undefined, 'metric'); card.append(node('span', label), node('strong', typeof value === 'number' ? number(value) : String(value ?? '데이터 없음'))); grid.append(card); } parent.append(grid);
|
||||
}
|
||||
function chart(parent: HTMLElement, values: any[], key: string, labelKey: string, color: string) {
|
||||
if (!values.length) return;
|
||||
const ns = 'http://www.w3.org/2000/svg', svg = document.createElementNS(ns, 'svg'); svg.setAttribute('viewBox', '0 0 800 180'); svg.setAttribute('role', 'img'); svg.setAttribute('aria-label', `${key} 추이`); svg.classList.add('chart');
|
||||
const max = Math.max(1, ...values.map(v => v[key]));
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const y = 140 - i * 60, line = document.createElementNS(ns, 'line'); line.setAttribute('x1', '35'); line.setAttribute('x2', '780'); line.setAttribute('y1', String(y)); line.setAttribute('y2', String(y)); line.setAttribute('stroke', '#e5e9f0'); svg.append(line);
|
||||
const text = document.createElementNS(ns, 'text'); text.setAttribute('x', '0'); text.setAttribute('y', String(y + 4)); text.textContent = number(max * i / 2); svg.append(text);
|
||||
}
|
||||
const step = 730 / Math.max(1, values.length);
|
||||
values.forEach((v, i) => {
|
||||
const rect = document.createElementNS(ns, 'rect'), height = v[key] / max * 120;
|
||||
rect.setAttribute('x', String(40 + i * step)); rect.setAttribute('y', String(140 - height)); rect.setAttribute('width', String(Math.max(1, step - 4))); rect.setAttribute('height', String(height)); rect.setAttribute('rx', '3'); rect.setAttribute('fill', color);
|
||||
const title = document.createElementNS(ns, 'title'); title.textContent = `${v[labelKey]}: ${v[key]}`; rect.append(title); svg.append(rect);
|
||||
if (i === 0 || i === values.length - 1 || (values.length < 10)) { const label = document.createElementNS(ns, 'text'); label.setAttribute('x', String(40 + i * step)); label.setAttribute('y', '165'); label.textContent = String(v[labelKey]).slice(labelKey === 'day' ? 5 : 5); svg.append(label); }
|
||||
}); parent.append(svg);
|
||||
}
|
||||
function distributions(parent: HTMLElement, title: string, rows: any[], unit = '') {
|
||||
parent.append(node('h3', title)); table(parent, ['구간', '표본 수'], rows.map(r => [`${number(r.lower)}${unit} ~ ${r.upper === null ? '' : `${number(r.upper)}${unit} 미만`}`, number(r.count)]));
|
||||
}
|
||||
function render() {
|
||||
content.replaceChildren(); if (!report) return;
|
||||
if (tab === 'overview' || tab === 'gameplay') {
|
||||
const g = report.clientGameplay, current = section('대전 요약 · 클라이언트 보고', '친구 대전 결과는 참가자별 보고 수입니다.');
|
||||
cards(current, [['플레이어', g.players], ['시작 대전', g.startedMatches], ['결과 보고', g.resultReports]]);
|
||||
table(current, ['결과', '보고 수'], g.outcomes.map((r: any) => [({ win: '승', loss: '패', draw: '무', aborted: '중단' } as any)[r.name] ?? r.name, r.count]));
|
||||
}
|
||||
if (tab === 'overview') {
|
||||
const o = report.overview, s = section('방문·플레이', '순방문자·플레이어는 전체 구간 중복 제거. 방문 지표에는 모드 필터 미적용.');
|
||||
cards(s, [['순방문자', o.visitors], ['방문 세션', o.visitSessions], ['페이지 조회', o.pageViews]]);
|
||||
table(s, ['지표', '값'], [['신규 방문자', rate(o.newVisitors)], ['재방문자', rate(o.returningVisitors)]]);
|
||||
const live = section('현재 현황', '과거 기간·분류 필터 미적용 · 활성 방문자: 보이는 화면의 최근 90초 활동 · 10초 갱신'); live.id = 'live'; renderLive();
|
||||
cards(section('HTTP 요청', '서버 시작 이후 · 사람 집계와 별도 · 관리자 조회 제외'), [['요청 수', report.collection.httpRequests]]);
|
||||
const active = section('DAU · WAU · MAU', '조회 종료일을 포함한 최근 1/7/30일의 ID 중복 제거'); table(active, ['기간', '방문자', '플레이어'], [['DAU', number(o.dau.visitors), number(o.dau.players)], ['WAU', number(o.wau.visitors), number(o.wau.players)], ['MAU', number(o.mau.visitors), number(o.mau.players)]]);
|
||||
const days = section('일별 추이', 'KST 날짜 · 방문자/플레이어는 일별 중복 제거'); chart(days, report.daily, 'visitors', 'day', '#326dde'); table(days, ['날짜', '방문자', '플레이어', '생성 대전', '페이지 조회'], report.daily.map((d: any) => [d.day, d.visitors, d.players, d.matches, d.pageViews]));
|
||||
const hours = section('시간별 추이', 'KST 시각 · 파란 막대: 순방문자'); chart(hours, report.hourly, 'visitors', 'hour', '#326dde'); table(hours, ['시간', '순방문자', '생성 대전'], report.hourly.map((d: any) => [d.hour.replace('T', ' '), d.visitors, d.matches]));
|
||||
const peak = section('최대 동시 이용 표본', '10초 간격 표본 최대값 · 순간 최대값 미측정'); cards(peak, [['표본 수', report.sampledPeak.count], ['활성 방문자 추정', report.sampledPeak.active]]);
|
||||
} else if (tab === 'acquisition') {
|
||||
for (const [key, label] of [['source', '유입 채널'], ['referrer', '외부 유입 도메인'], ['device', '기기'], ['browser', '브라우저']]) {
|
||||
table(section(label), ['분류', '방문자', '페이지 조회'], report.acquisition[key].map((g: any) => [g.name, g.visitors, g.pageViews]));
|
||||
}
|
||||
table(section('초대 링크', '페이지 조회 기준'), ['초대 링크 조회', '전체 조회'], [[report.acquisition.invitation.invited, report.acquisition.invitation.total]]);
|
||||
table(section('조작'), ['조작', '횟수'], report.flow.actions.map((r: any) => [({ bot_start: '봇 시작', room_create: '방 생성', room_join: '방 참가', rematch: '재대전', room_leave: '나가기' } as any)[r.name] ?? r.name, r.count]));
|
||||
table(section('화면 전환'), ['화면', '전환 수'], report.flow.screens.map((r: any) => [r.name, r.count]));
|
||||
} else if (tab === 'revisit') {
|
||||
table(section('방문 재방문', '첫 방문 KST 날짜 기준 · 관찰일이 끝나지 않은 코호트는 관찰 중'), ['첫 날짜', '코호트 인원', 'D1', 'D7'], report.revisit.visitCohorts.map((c: any) => [c.day, c.size, c.d1 === null ? '관찰 중' : rate(c.d1), c.d7 === null ? '관찰 중' : rate(c.d7)]));
|
||||
const r = report.revisit;
|
||||
const active = section('활성 이용 시간', '보이는 화면의 연속 신호 간격만 합산 · 90초 초과 공백 제외 · 세션 전체 누적값, 기간 경계 분할 미적용'); cards(active, [['중앙값', r.activeMs.median === null ? '데이터 없음' : `${number(r.activeMs.median / 1000)}초`]]); distributions(active, '활성 시간(ms)', r.activeMs.distribution);
|
||||
} else if (tab === 'gameplay') {
|
||||
table(section('클라이언트 오류'), ['오류 코드', '횟수'], report.errors.client.map((e: any) => [e.name, e.count]));
|
||||
} else {
|
||||
const users = section('익명 사용자', '조회 구간 방문자 · 최대 50명 · 표시 ID는 내부 익명 ID의 앞 8자리');
|
||||
table(users, ['ID', '첫 방문', '최근 방문', '보고 대전', '조회'], report.users.map((u: any) => {
|
||||
const b = node('button', '상세', 'quiet'); b.onclick = () => { detailId = u.playerId; detailPage = 1; void loadDetail(); }; return [short(u.playerId), time(u.firstVisitAt), time(u.lastVisitAt), u.clientMatches, b];
|
||||
}));
|
||||
content.append(node('div', undefined, 'user-detail')); if (detailId) void loadDetail();
|
||||
}
|
||||
}
|
||||
function renderLive() {
|
||||
const el = document.getElementById('live'); if (!el || !report) return;
|
||||
el.querySelector('.metric-grid')?.remove(); el.querySelector('.live-time')?.remove(); const l = report.live;
|
||||
cards(el, [['활성 방문자 추정', l.active], ['대전 중 사람', '미수집'], ['대전 수', '미수집']]);
|
||||
el.append(node('p', `조회 ${time(l.observedAt)} · 마지막 활동 ${time(l.lastActivityAt)}${l.lastActivityAt && Date.now() - +new Date(l.lastActivityAt) > 90000 ? ' · 최근 활동 없음' : ''}`, 'admin-meta live-time'));
|
||||
}
|
||||
function collection() {
|
||||
if (!report) return;
|
||||
const c = report.collection;
|
||||
document.getElementById('collection')!.textContent = `조회 ${time(c.now)} · 최근 수집 성공 ${time(c.lastSuccessAt)} · 실패 ${c.failures} · 버림 ${c.dropped} · 제한 ${c.rateLimited} · 대기 ${c.queued} · 기록 저장 실패 ${c.resultSaveFailures} · 서버 시작 ${time(c.restartedAt)}`;
|
||||
document.getElementById('error')!.textContent = reportLoadError || report.integrityIssues?.join(' · ') || '';
|
||||
}
|
||||
async function load() {
|
||||
const params = new URLSearchParams(); const values = new FormData(form);
|
||||
for (const key of ['start', 'end', 'mode', 'audience', 'device', 'browser', 'source']) {
|
||||
const value = String(values.get(key) ?? ''); if (!value) continue;
|
||||
params.set(key, ['start', 'end'].includes(key) ? new Date(`${value}:00+09:00`).toISOString() : value);
|
||||
}
|
||||
try {
|
||||
const response = await fetch(`/api/admin/analytics/report?${params}`, { cache: 'no-store' }); const data = await response.json();
|
||||
if (!response.ok) throw new Error(data.error ?? '조회하지 못했습니다.'); report = data; reportLoadError = '';
|
||||
document.getElementById('range')!.textContent = `${time(report.range.start)} 이상 ~ ${time(report.range.end)} 미만 · 상세 보관 시작 ${time(report.range.retentionStart)} · 최초 수집 ${time(report.range.collectionStart)}`;
|
||||
collection(); render();
|
||||
} catch (error) { reportLoadError = error instanceof Error ? error.message : '조회하지 못했습니다.'; document.getElementById('error')!.textContent = reportLoadError; }
|
||||
}
|
||||
async function loadDetail() {
|
||||
const holder = content.querySelector<HTMLElement>('.user-detail'); if (!holder || !detailId) return;
|
||||
const id = detailId, page = detailPage;
|
||||
try {
|
||||
const response = await fetch(`/api/admin/analytics/users/${encodeURIComponent(id)}?page=${page}&pageSize=20`); const d = await response.json();
|
||||
if (!response.ok) throw new Error(d.error); if (detailId !== id || detailPage !== page) return;
|
||||
holder.replaceChildren(node('h2', `사용자 ${short(d.playerId)}`));
|
||||
cards(holder, [['첫 방문', time(d.firstVisitAt)], ['최근 방문', time(d.lastVisitAt)]]);
|
||||
holder.append(node('h3', '접속 세션별 전적 · 클라이언트 보고'));
|
||||
table(holder, ['게임 세션', '모드', '승', '패', '무', '현재 연승', '최고 연승'], (d.gameSessions ?? []).flatMap((s: any) => ['bot', 'friend'].map(m => [short(s.gameSessionId), m === 'bot' ? '봇' : '친구', s.stats[m].wins, s.stats[m].losses, s.stats[m].draws, s.stats[m].current, s.stats[m].best])));
|
||||
holder.append(node('h3', '방문 세션')); table(holder, ['시작', '최근 활동', '활성 시간', '기기', '브라우저', '유입'], d.sessions.map((s: any) => [time(s.startedAt), time(s.lastAt), `${number(s.activeMs / 1000)}초`, s.metadata.device, s.metadata.browser, s.metadata.source]));
|
||||
holder.append(node('h3', '이벤트 흐름')); table(holder, ['시각', '유형', '조작·코드', '화면', '대전'], d.events.map((e: any) => [time(e.occurredAt), e.eventType, e.action ?? e.code ?? '—', e.screen, short(e.matchId)]));
|
||||
const pager = node('div', undefined, 'pager'), prev = node('button', '이전'), next = node('button', '다음'); (prev as HTMLButtonElement).disabled = page <= 1; (next as HTMLButtonElement).disabled = !d.hasMore;
|
||||
prev.onclick = () => { detailPage--; void loadDetail(); }; next.onclick = () => { detailPage++; void loadDetail(); }; pager.append(prev, node('span', `${page} 페이지`), next); holder.append(pager);
|
||||
} catch (error) { holder.textContent = error instanceof Error ? error.message : '상세 조회 실패'; }
|
||||
}
|
||||
const localDate = (value: number) => new Date(value + 9 * 3600000).toISOString().slice(0, 16);
|
||||
(form.elements.namedItem('start') as HTMLInputElement).value = localDate(Date.now() - 7 * 86400000);
|
||||
(form.elements.namedItem('end') as HTMLInputElement).value = localDate(Date.now());
|
||||
form.onsubmit = e => { e.preventDefault(); void load(); };
|
||||
document.querySelectorAll<HTMLButtonElement>('[data-tab]').forEach(b => { b.onclick = () => { tab = b.dataset.tab!; document.querySelectorAll('[data-tab]').forEach(n => n.setAttribute('aria-pressed', String(n === b))); render(); }; });
|
||||
setInterval(async () => {
|
||||
if (!report || document.hidden) return;
|
||||
try { const response = await fetch('/api/admin/analytics/live', { cache: 'no-store' }); if (!response.ok) return; const data = await response.json(); report.live = data.live; report.collection = data.collection; renderLive(); collection(); } catch {}
|
||||
}, 10000);
|
||||
void load();
|
||||
@@ -0,0 +1,6 @@
|
||||
<!doctype html><html lang="ko"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>방문·게임 분석</title></head><body>
|
||||
<div class="admin-app"><header><h1>방문·게임 분석</h1><span>익명 브라우저 기준 · Asia/Seoul</span></header>
|
||||
<form id="filters"><label>시작<input name="start" type="datetime-local" required></label><label>종료<input name="end" type="datetime-local" required></label><label>모드<select name="mode"><option value="">전체</option><option value="bot">봇</option><option value="friend">친구</option></select></label><label>방문 유형<select name="audience"><option value="">전체</option><option value="new">신규</option><option value="returning">재방문</option></select></label><label>기기<select name="device"><option value="">전체</option><option value="pc">PC</option><option value="mobile">모바일</option><option value="tablet">태블릿</option></select></label><label>브라우저<select name="browser"><option value="">전체</option><option>Chrome</option><option>Edge</option><option>Firefox</option><option>Safari</option><option>Other</option></select></label><label>유입 채널<input name="source" maxlength="80"></label><button class="primary" type="submit">조회</button></form>
|
||||
<p id="range" class="admin-meta"></p><p id="collection" class="admin-meta" role="status"></p><p id="error" role="alert"></p>
|
||||
<nav aria-label="분석 메뉴"><button data-tab="overview" aria-pressed="true">개요</button><button data-tab="acquisition" aria-pressed="false">유입·흐름</button><button data-tab="revisit" aria-pressed="false">재방문</button><button data-tab="gameplay" aria-pressed="false">게임</button><button data-tab="users" aria-pressed="false">사용자</button></nav>
|
||||
<main id="report"></main></div><script type="module" src="../../admin.ts"></script></body></html>
|
||||
@@ -0,0 +1,62 @@
|
||||
import { ACTIVITY_INTERVAL_MS, ANALYTICS_BATCH_SIZE, RETRY_INTERVAL_MS, RETRY_MAX_AGE_MS, RETRY_QUEUE_LIMIT } from '../../constants/analytics.js';
|
||||
interface AnalyticsTransport { events(events: Record<string, unknown>[]): Promise<unknown>; activity(visible: boolean): Promise<unknown> }
|
||||
export function createClientAnalytics(transport: AnalyticsTransport) {
|
||||
const queue = new Map<string, { data: Record<string, unknown>; at: number; attempts: number }>();
|
||||
let sending = false, pageSent = false, enabled = false, destroyed = false, lastVisible = -Infinity, lastHidden = -Infinity;
|
||||
function event(eventType: string, fields: Record<string, unknown> = {}) {
|
||||
if (queue.size >= RETRY_QUEUE_LIMIT) return;
|
||||
const eventId = crypto.randomUUID(); queue.set(eventId, { data: { eventId, eventType, ...fields }, at: Date.now(), attempts: 0 });
|
||||
if (queue.size >= ANALYTICS_BATCH_SIZE) void flush();
|
||||
}
|
||||
async function flush() {
|
||||
if (sending || !enabled || destroyed) return;
|
||||
sending = true;
|
||||
for (const [id, item] of queue) if (Date.now() - item.at > RETRY_MAX_AGE_MS || item.attempts >= 6) queue.delete(id);
|
||||
while (queue.size && !destroyed) {
|
||||
const batch = [...queue.entries()].slice(0, ANALYTICS_BATCH_SIZE);
|
||||
try {
|
||||
await transport.events(batch.map(([, item]) => ({ ...item.data, delayed: ++item.attempts > 1 })));
|
||||
batch.forEach(([id]) => queue.delete(id));
|
||||
} catch (error) {
|
||||
if ((error as { code?: string }).code === 'INVALID_ANALYTICS_EVENT') batch.forEach(([id]) => queue.delete(id));
|
||||
break;
|
||||
}
|
||||
if (queue.size < ANALYTICS_BATCH_SIZE) break;
|
||||
}
|
||||
sending = false;
|
||||
}
|
||||
function activity() {
|
||||
if (!enabled || destroyed) return;
|
||||
const visible = !document.hidden, now = Date.now();
|
||||
if (now - (visible ? lastVisible : lastHidden) < ACTIVITY_INTERVAL_MS) return;
|
||||
if (visible) lastVisible = now; else lastHidden = now;
|
||||
void transport.activity(visible).catch(() => {});
|
||||
}
|
||||
const heartbeat = setInterval(() => { if (!document.hidden) activity(); }, ACTIVITY_INTERVAL_MS);
|
||||
const retry = setInterval(() => { void flush(); }, RETRY_INTERVAL_MS);
|
||||
document.addEventListener('visibilitychange', activity);
|
||||
return {
|
||||
ready() {
|
||||
enabled = true;
|
||||
activity();
|
||||
if (!pageSent) {
|
||||
pageSent = true;
|
||||
const ua = navigator.userAgent;
|
||||
const browser = /Edg\/(\d+)/.exec(ua) ?? /Firefox\/(\d+)/.exec(ua) ?? /Chrome\/(\d+)/.exec(ua) ?? /Version\/(\d+).*Safari/.exec(ua);
|
||||
const name = /Edg\//.test(ua) ? 'Edge' : /Firefox\//.test(ua) ? 'Firefox' : /Chrome\//.test(ua) ? 'Chrome' : /Safari\//.test(ua) ? 'Safari' : 'Other';
|
||||
const params = new URLSearchParams(location.search);
|
||||
event('page_view', { screen: 'home', metadata: {
|
||||
device: /iPad|Tablet/.test(ua) || (/Macintosh/.test(ua) && navigator.maxTouchPoints > 1) ? 'tablet' : /Mobi|Android|iPhone/.test(ua) ? 'mobile' : 'pc',
|
||||
browser: `${name}${browser ? ` ${browser[1]}` : ''}`, referrer: document.referrer,
|
||||
source: params.get('utm_source'), medium: params.get('utm_medium'), campaign: params.get('utm_campaign'), invitation: params.has('room'),
|
||||
} });
|
||||
}
|
||||
},
|
||||
screen(screen: string) { event('screen_view', { screen }); },
|
||||
click(action: string) { const flowId = crypto.randomUUID(); event('ui_action', { action, flowId }); return flowId; },
|
||||
error(code: string, screen: string) { event('client_error', { code, screen }); },
|
||||
gameStarted(matchId: string, mode: string) { event('game_started', { matchId, mode }); },
|
||||
gameFinished(matchId: string, mode: string, outcome: string, reason: string) { event('game_finished', { matchId, mode, outcome, reason }); },
|
||||
destroy() { destroyed = true; clearInterval(heartbeat); clearInterval(retry); document.removeEventListener('visibilitychange', activity); queue.clear(); },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { GAME_SESSION_STORAGE_KEY } from '../../constants/recording.js';
|
||||
import type { ActiveMatch, GameSessionState, PeerRoom, RecordAck, ResultSubmission } from '../shared/p2p-types.js';
|
||||
import type { Ack, Mode } from '../shared/types.js';
|
||||
import type { SessionStorage } from './result-reporter.js';
|
||||
|
||||
export class ApiClientError extends Error {
|
||||
constructor(public status: number, public code: string, message: string) { super(message); }
|
||||
}
|
||||
interface SavedSession { createRequestId: string; session?: GameSessionState; roomRequestId?: string; leavingRoomId?: string }
|
||||
export function createGameApi(storage: SessionStorage, send: typeof fetch = globalThis.fetch.bind(globalThis)) {
|
||||
let saved: SavedSession | undefined;
|
||||
const persist = () => storage.setItem(GAME_SESSION_STORAGE_KEY, JSON.stringify(saved));
|
||||
const session = (): GameSessionState => { if (!saved?.session) throw new Error('SESSION_NOT_READY'); return saved.session; };
|
||||
async function request<T>(path: string, method: 'GET' | 'POST' = 'GET', body?: unknown, authenticated = true): Promise<T> {
|
||||
const headers: Record<string, string> = { 'content-type': 'application/json' };
|
||||
if (authenticated) { headers.authorization = `Bearer ${session().token}`; headers['x-game-session'] = session().gameSessionId; }
|
||||
const response = await send(path, { method, headers, ...(body === undefined ? {} : { body: JSON.stringify(body) }) });
|
||||
const result = await response.json() as Ack<T>;
|
||||
if (!result.ok) throw new ApiClientError(response.status, result.code, result.message);
|
||||
if (!response.ok) throw new ApiClientError(response.status, 'SERVER_ERROR', '서버에 연결하지 못했습니다.');
|
||||
return result.data;
|
||||
}
|
||||
async function sync() {
|
||||
const guestToken = saved?.session?.guestToken;
|
||||
saved!.session = { ...await request<GameSessionState>('/api/game-session'), ...(guestToken ? { guestToken } : {}) };
|
||||
persist(); return session();
|
||||
}
|
||||
async function leaveRoom(id: string) {
|
||||
session(); saved!.leavingRoomId = id; persist();
|
||||
try { await request('/api/rooms/' + encodeURIComponent(id) + '/leave', 'POST', { requestId: crypto.randomUUID() }); }
|
||||
catch (error) { if (!(error instanceof ApiClientError) || ![404, 410].includes(error.status)) throw error; }
|
||||
delete saved!.leavingRoomId; persist();
|
||||
}
|
||||
return {
|
||||
get session() { return session(); },
|
||||
async ensureSession(guestToken?: string) {
|
||||
if (!saved) {
|
||||
const raw = storage.getItem(GAME_SESSION_STORAGE_KEY);
|
||||
if (raw) { try { saved = JSON.parse(raw) as SavedSession; } catch { /* recover invalid tab storage */ } }
|
||||
if (!saved?.createRequestId) {
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(32));
|
||||
const createRequestId = btoa(String.fromCharCode(...bytes)).replaceAll('+', '-').replaceAll('/', '_').replaceAll('=', '');
|
||||
saved = { createRequestId }; persist();
|
||||
}
|
||||
}
|
||||
if (saved.session) return sync();
|
||||
saved.session = await request<GameSessionState>('/api/game-sessions', 'POST', { createRequestId: saved.createRequestId, guestToken }, false);
|
||||
persist(); return session();
|
||||
},
|
||||
sync, request, leaveRoom,
|
||||
async resumeRoomLeave() { if (!saved?.leavingRoomId) return false; await leaveRoom(saved.leavingRoomId); return true; },
|
||||
async createRoom() {
|
||||
session(); saved!.roomRequestId ??= crypto.randomUUID(); persist();
|
||||
const room = await request<PeerRoom>('/api/rooms', 'POST', { requestId: saved!.roomRequestId });
|
||||
delete saved!.roomRequestId; persist(); return room;
|
||||
},
|
||||
async registerMatch(input: { requestId: string; matchId: string; mode: Mode; roomId?: string; expectedPreviousSeq: number }) {
|
||||
const active = await request<ActiveMatch>('/api/game-session/matches', 'POST', input);
|
||||
session().activeMatch = active; persist(); return active;
|
||||
},
|
||||
async record(input: ResultSubmission) {
|
||||
const ack = await request<RecordAck>('/api/game-session/results', 'POST', input);
|
||||
session().stats = ack.stats; session().lastAppliedSeq = Math.max(session().lastAppliedSeq, input.seq);
|
||||
if (session().activeMatch?.matchId === input.matchId) delete session().activeMatch;
|
||||
persist(); return ack;
|
||||
},
|
||||
};
|
||||
}
|
||||
export type GameApi = ReturnType<typeof createGameApi>;
|
||||
@@ -0,0 +1,474 @@
|
||||
import type { ActionId } from '../shared/types.js';
|
||||
import { COSMETIC_COLORS, SKIN_TONES, type EquipmentId } from '../../constants/cosmetics.js';
|
||||
import { normalizeAppearance, type FighterAppearance } from '../shared/cosmetics.js';
|
||||
|
||||
function equipped(appearance: FighterAppearance | undefined, item: EquipmentId) {
|
||||
return !appearance || !!appearance.equipment?.includes(item);
|
||||
}
|
||||
|
||||
function fighterPalette(index: number, appearance?: FighterAppearance) {
|
||||
const palette = (appearance?.characterId ?? index) === 1
|
||||
? '--skin:#b59173;--skin-shade:#786650;--skin-light:#dcc0a0;--suit:#455862;--suit-dark:#293840;--trim:#d7d5bf;--hair:#d7d9cf;--hair-shadow:#7e8d90'
|
||||
: '--skin:#d7ad8a;--skin-shade:#996b55;--skin-light:#f0cdaa;--suit:#aa5740;--suit-dark:#613b33;--trim:#d3bf99;--hair:#242b30;--hair-shadow:#454e53';
|
||||
if (!appearance) return palette;
|
||||
const selected = normalizeAppearance(appearance);
|
||||
const color = COSMETIC_COLORS.find(color => color.id === selected.colorId) ?? COSMETIC_COLORS[0];
|
||||
const skin = SKIN_TONES.find(tone => tone.id === selected.skinTone) ?? SKIN_TONES[0];
|
||||
return palette
|
||||
.replace(/--skin:[^;]+;--skin-shade:[^;]+;--skin-light:[^;]+;/,
|
||||
`--skin:${skin.skin};--skin-shade:${skin.shade};--skin-light:${skin.light};`)
|
||||
.replace(/--suit:[^;]+;--suit-dark:[^;]+;--trim:[^;]+;/,
|
||||
`--suit:${color.suit};--suit-dark:${color.suitDark};--trim:${color.trim};`)
|
||||
.replace(/--hair:[^;]+;--hair-shadow:[^;]+/,
|
||||
selected.hairStyle === 'white' ? '--hair:#d7d9cf;--hair-shadow:#7e8d90' : '--hair:#242b30;--hair-shadow:#454e53');
|
||||
}
|
||||
|
||||
/** Shared head drawing: full-body art and the hit close-up always depict the same fighter. */
|
||||
function fighterHead(index: number, includeNeck = true, appearance?: FighterAppearance) {
|
||||
const veteran = index === 1;
|
||||
const hairStyle = appearance ? normalizeAppearance(appearance).hairStyle : veteran ? 'white' : 'black';
|
||||
const bald = hairStyle === 'bald', whiteHair = hairStyle === 'white';
|
||||
const headband = appearance ? equipped(appearance, 'headband') : !veteran;
|
||||
const eyewear = appearance?.equipment?.includes('sunglasses') ? 'sunglasses'
|
||||
: appearance?.equipment?.includes('glasses') ? 'glasses' : undefined;
|
||||
return `<g class="portrait" stroke="#232a2c" stroke-width="1.8" stroke-linejoin="round">
|
||||
${bald ? '' : whiteHair ? '<path d="m110 36-7-12 14 2 7-14 9 11 20-7-5 11 15 4-7 26-43 4Z" fill="var(--hair)"/><path d="m146 29 10 3-4 22-14-7Z" fill="var(--hair-shadow)"/>' : '<path d="m113 33-15-7 14-3-1-13 16 9 16-14 2 16 19-5-9 12 11 8-15 27-40-6Z" fill="var(--hair)"/><path d="M109 29 103 35 100 44 109 39 113 32Z" fill="var(--hair)"/>'}
|
||||
${includeNeck ? '<path d="m116 66-3 17 14 12 24-12-9-20Z" fill="var(--skin)"/>\n <path d="m118 69 21-5 9 16-20 3-5 8-6-10Z" fill="var(--skin-shade)" stroke="none"/>' : ''}
|
||||
<path d="m113 44-6-3-2 9 7 9m39-14 7-5 2 8-7 10" fill="var(--skin)"/>
|
||||
<path${bald ? ' class="bald-scalp"' : ''} d="${bald
|
||||
? veteran ? 'M110 45C108 29 116 20 130 20C145 19 154 29 153 44L151 60 142 74 127 78 114 66Z' : 'M111 47C109 30 117 21 131 21C145 21 154 32 152 47L152 57 144 72 130 77 116 67Z'
|
||||
: veteran ? 'm113 33 19-6 21 8-2 25-9 14-15 4-13-12-4-20Z' : 'm113 34 18-9 20 10 1 22-8 15-14 5-14-10-5-20Z'}" fill="var(--skin)"/>
|
||||
${bald ? '<path d="M117 33Q124 23 135 26" stroke="var(--skin-light)" stroke-width="3" fill="none"/>' : ''}
|
||||
<path d="m143 34 7 3 1 18-8 16-13 6-8-7 14-3 6-11-4-9Z" fill="var(--skin-shade)" stroke="none"/>
|
||||
<path d="m116 52 5 7-5 3m27-10-4 9 7-2m-18-12-3 11 6 1" fill="none" stroke-width="1.3"/>
|
||||
${bald ? '' : whiteHair ? '<path d="m111 42-2-12 18-10 25 4 7 8-11-1-12 8 2-9-11 10-5-4-6 10Z" fill="var(--hair)"/><path d="m121 24 8 4 14-3m-6 8 13-5" fill="none" stroke="var(--hair-shadow)"/>' : '<path d="m109 43 1-17 18-7 25 7 5 15-14-10-2 14-10-11-8 12-1-14-8 12Z" fill="var(--hair)"/><path d="m116 24 9-3m13 4 11 3" stroke="var(--hair-shadow)" fill="none"/>'}
|
||||
${veteran ? '<path d="m118 68 5 3m0-1 3 3m7-1 2 2m3-5 2 1m3-4 2 1" fill="none" stroke-width="1"/>' : ''}
|
||||
${headband ? '<path d="m111 34 42-2-1 6-40 3Z" fill="#864237"/><path d="m111 37-19 8-19-5 13 13 24-11-12 18 17-15Z" fill="#864237"/>' : ''}
|
||||
<g class="face face-ready">
|
||||
<path d="m114 47 12 3-9 3Zm23 3 12-5-3 8Z" fill="#f0eee2" stroke-width="1.3"/>
|
||||
<path d="m121 49 1 3m21-4 1 3" stroke-width="2.6"/><path d="m114 44 12 4m12-1 12-5" stroke-width="2.5"/>
|
||||
<path d="m123 65 13-1" fill="none"/><path d="m117 56 5 1m19-1 5-2" fill="none" stroke-width=".8"/>
|
||||
</g>
|
||||
<g class="face face-focus">
|
||||
<path d="m113 48 13 3-10 2Zm23 2 13-5-4 8Z" fill="#f4f2e9" stroke-width="1.4"/>
|
||||
<path d="m122 50v2m21-4v3m-30-8 13 6m12-1 13-6" stroke-width="2.5"/>
|
||||
<path d="m122 63 16-1-3 6-11-1Z" fill="#ede6d4" stroke-width="1.2"/><path d="m128 64 5 1" fill="none" stroke-width=".7"/>
|
||||
</g>
|
||||
<g class="face face-win">
|
||||
<path d="m115 48 11 2m13-1 10-3" stroke-width="2.1"/><path d="m122 65 8 2 10-5" fill="none"/>
|
||||
<path d="m122 49v3m22-5v3" stroke-width="2.4"/>
|
||||
</g>
|
||||
<g class="face face-loss">
|
||||
<path d="m113 48 13 4m12-1 12-5m-28 18 14-1" fill="none" stroke-width="2.2"/>
|
||||
<path d="m116 55 5 4m18-4 5-3m-24 6 2 2m-1-3 2 2m18-4 3-1" fill="none" stroke-width=".8"/>
|
||||
</g>
|
||||
<g class="face face-draw">
|
||||
<path d="m114 47 12 3-10 3Zm24 3 11-3-3 6Z" fill="#ece9df" stroke-width="1.2"/><path d="m122 49v3m21-3v3" stroke-width="2.5"/>
|
||||
<path d="m124 65 12 1m-22-23 12 4m12 0 12-4" fill="none" stroke-width="1.5"/>
|
||||
</g>
|
||||
<g class="face face-shock">
|
||||
<path d="m112 44 15 3-3 10-9-2Zm25 4 16-8-2 13-11 4Z" fill="#fffdf1" stroke-width="1.6"/>
|
||||
<ellipse cx="122" cy="51" rx="1.9" ry="3" fill="#232a2c" stroke="none"/><ellipse cx="143" cy="50" rx="1.9" ry="3.2" fill="#232a2c" stroke="none"/>
|
||||
<path d="m112 40 14 3m11 0 16-8" fill="none" stroke-width="2.2"/>
|
||||
<path d="m121 63 15-3 5 11-10 5-10-5Z" fill="#33323a"/><path d="m123 63 11-1 1 3-11 2Z" fill="#faf4e7" stroke-width=".7"/>
|
||||
<path d="m114 57 5 3m-6-1 4 3m31-7 4-2m-4 5 4-2m-23-18 1 6m4-7 0 6" fill="none" stroke-width=".7"/>
|
||||
</g>
|
||||
${eyewear ? `<g class="eyewear eyewear-${eyewear}" stroke="#232a2c" stroke-width="2" stroke-linecap="round">
|
||||
<path d="M111 46 107 43M129 49Q132 47 136 49M153 43 158 40" fill="none"/>
|
||||
<path d="M111 43 129 46 127 59 113 57Z M135 46 154 39 153 55 138 59Z" fill="${eyewear === 'sunglasses' ? '#26343d' : '#d8f0f2'}" fill-opacity="${eyewear === 'sunglasses' ? '1' : '.14'}"/>
|
||||
<path d="m115 47 4 1m21 0 8-3" fill="none" stroke="#e4f1ec" stroke-width="1" opacity=".65"/>
|
||||
</g>` : ''}
|
||||
</g>`;
|
||||
}
|
||||
|
||||
/** Athletic ink-and-cel figures retain the arena's existing 260 × 300 geometry. */
|
||||
export function fighterArt(index: number, closeup = false, appearance?: FighterAppearance) {
|
||||
const characterId = appearance?.characterId ?? index;
|
||||
const veteran = characterId === 1;
|
||||
const shoes = equipped(appearance, 'shoes'), wristbands = equipped(appearance, 'wristbands');
|
||||
return `<svg class="fighter-art" viewBox="${closeup ? '72 10 136 130' : '0 0 260 300'}" fill="none" aria-hidden="true" style="${fighterPalette(index, appearance)}">
|
||||
<ellipse class="ground-shadow" cx="132" cy="285" rx="88" ry="7" fill="#222c3025"/>
|
||||
<g class="character" stroke="#232a2c" stroke-width="2.4" stroke-linejoin="round" stroke-linecap="round">
|
||||
<g class="legs legs-stance">
|
||||
<path d="m104 160-18 43-22 60 24 8 37-56 13-27 18 29 26 47 24-10-20-63-32-35Z" fill="var(--suit)"/>
|
||||
<path d="m105 166-8 40-23 51 15 7 27-53 18-24 5-23m3 15 24 37 19 40 10-5-20-58-20-31Z" fill="var(--suit-dark)" stroke="none"/>
|
||||
<path d="m111 179-12 34-13 11m39-24-16 20m42-29 10 26 18 15m-11 1 9 10" fill="none" stroke-width="1.5"/>
|
||||
${shoes ? `<path d="M66 263 88 271 83 280 59 279Z M184 263 203 255 207 280 185 281Z" fill="var(--skin)"/>
|
||||
<path d="M60 275Q69 280 85 276L83 289Q64 293 44 292Q39 292 41 287Q43 283 54 281Z M185 276Q195 281 207 276L211 282Q218 284 217 290Q211 294 182 292L178 287Z" fill="#30383c"/>
|
||||
<path d="M42 289Q60 291 82 287M182 289Q200 291 215 289" stroke-width="1"/>` : `<g class="bare-feet">
|
||||
<path d="M66 263 88 271 83 279Q84 285 80 289L48 291Q40 291 40 287Q40 284 46 283L57 279Q62 275 66 263Z M184 263 203 255 206 277Q209 281 216 284Q222 286 218 290Q215 292 208 291L185 290Q180 289 182 283L187 276Z" fill="var(--skin)"/>
|
||||
<path d="M61 280Q69 284 80 280M190 279Q198 283 206 280" stroke="var(--skin-shade)" stroke-width="1.4"/>
|
||||
<path d="m47 284 1 5m5-6 1 5m5-6 1 5m147-2-1 5m7-4-1 5m7-3-1 3" stroke-width="1"/>
|
||||
</g>`}
|
||||
</g>
|
||||
<g class="legs legs-airborne">
|
||||
<path d="m102 158-34 42-27 1-14 22 45 6 42-35 37 20 27-9-18-46Z" fill="var(--suit)"/>
|
||||
<path d="m116 175-46 41-29-3-6 10 37 6 42-35 34 19 19-6Z" fill="var(--suit-dark)" stroke="none"/>
|
||||
<path d="m146 191 30 12 14 42 22 7-7 23-32-17-19-34-27-14" fill="var(--suit)"/>
|
||||
${shoes ? `<path d="M192 249 211 253 207 270 190 265Z M43 203 43 225 32 225 30 204Z" fill="var(--skin)"/>
|
||||
<path d="M198 257Q203 263 213 261L216 276Q218 283 211 285L196 280Q188 277 188 271L190 264Z M32 204Q28 214 35 225L24 229Q19 230 18 223L16 210Q16 203 22 202Z" fill="#30383c"/>
|
||||
<path d="M194 276 208 282Q214 284 214 279M21 207 22 221Q22 226 27 226" stroke-width="1"/>` : `<g class="bare-feet">
|
||||
<path d="M192 249 211 253 207 266Q209 273 215 278Q219 284 214 286Q210 287 205 282L194 276Q188 273 189 267Z M43 203 43 225 32 224Q27 226 23 230Q18 232 16 227L15 213Q14 205 20 202L30 204Z" fill="var(--skin)"/>
|
||||
<path d="M195 267Q198 269 201 268M26 208Q24 215 28 221" stroke="var(--skin-shade)" stroke-width="1.3"/>
|
||||
<path d="m205 277-2 4m6-1-2 3m6 0-1 2M19 221l5-1m-5-4 4-1m-4-4 4-1" stroke-width="1"/>
|
||||
</g>`}
|
||||
<path d="m83 192-9 14-23 7m103-13 8 11m-1 14 7 14" stroke-width="1.5"/>
|
||||
</g>
|
||||
<path d="${veteran ? 'm110 77-28 9-5 20 18 28 8 34 50 4 16-41 10-27-22-24-17-4Z' : 'm113 77-26 10-7 21 20 26 3 34 49 4 13-41 7-30-25-23Z'}" fill="var(--suit)"/>
|
||||
<path d="m111 78 17 16 15-17 11 12-23 47-32-39Z" fill="var(--skin)"/>
|
||||
<path d="m112 89 12 12 4 18 10-14 9-24-8 0-11 12-11-13Z" fill="var(--skin-shade)" stroke="none"/>
|
||||
<path d="m111 102 12 3m10 3 7-6m-14 10 2 6" fill="none" stroke-width="1.2"/>
|
||||
${veteran ? '<path d="m90 86 21-8 6 8-17 30-7 36-9-48Z" fill="var(--suit-dark)"/><path d="m151 82 20 9-9 26-17-12Z" fill="var(--trim)"/><path d="m154 91 12 5m-15 2 12 5m-7 0 5 3" stroke="var(--suit-dark)" stroke-width="2"/>' : '<path d="m109 79-12 13 30 43 24-51-8-7-16 39Z" fill="var(--trim)"/><path d="m100 87 7 2m-9 3 7 3m37-10-9 22" fill="none" stroke-width="1.1"/>'}
|
||||
<path d="m155 112-12 32-6 17 19 2 6-40-2-12Zm-56 18 14 17 23 12-32 1Z" fill="var(--suit-dark)" stroke="none"/>
|
||||
<path d="m105 133 9 11m28-16-7 20m13-4-5 12m-34-7 16 6" fill="none" stroke-width="1.4"/>
|
||||
<path d="m101 155 55 4-3 12-50-4Z" fill="#303238"/><path d="m138 165 7 30 11 8 1-17-10-23-3 2-1 38-9 7-3-40Z" fill="${veteran ? '#9b5448' : '#484c4b'}"/>
|
||||
${fighterHead(characterId, true, appearance)}
|
||||
<g class="pose pose-ready">
|
||||
<path d="m88 90-14 5-18 28-5 17 15 9 29-27 8-14Z" fill="var(--skin)"/><path d="m75 102-15 29 4 10 21-17 8-18" fill="var(--skin-shade)" stroke="none"/>
|
||||
<path d="m54 137 20-21 12-19 13 7-10 23-24 23Z" fill="var(--skin)"/>${wristbands ? '<path d="m76 113 15-16 11 8-12 20Z" fill="var(--trim)"/>' : ''}
|
||||
<path d="m87 100 1-13 10-9 13 4 4 10-9 12-11 1Z" fill="var(--skin)"/><path d="m94 86 1 7m5-9 1 7m5-5-1 7m-7 8 6-4" stroke-width="1.2"/>
|
||||
<path d="m163 91 15 13 14 27-9 18-18-18-15-27Z" fill="var(--skin)"/><path d="m166 101 12 22 7 17-12-11-16-24" fill="var(--skin-shade)" stroke="none"/>
|
||||
<path d="m178 130 14-26 14 4-9 32-13 10-12-9Z" fill="var(--skin)"/>${wristbands ? '<path d="m187 110 3-12 18 5-4 16Z" fill="var(--trim)"/>' : ''}
|
||||
<path d="m190 100-3-12 7-9 16 3 5 9-8 15Z" fill="var(--skin)"/><path d="m194 84 0 8m6-8 0 9m6-6-1 8m-17-3 7 7" stroke-width="1.2"/>
|
||||
<path d="${wristbands ? 'm66 122 6-5m98 2 6 5m-93-6 10 4m-11 0 9 4m104-20 13 4m-14 1 13 4' : 'm66 122 6-5m98 2 6 5'}" fill="none" stroke-width="1"/>
|
||||
</g>
|
||||
<g class="pose pose-charge">
|
||||
<path d="m88 90-14 10-13 39 10 21 22-8-7-18 15-28Z M163 90l17 12 18 38-5 21-23-8 3-19-21-29Z" fill="var(--skin)"/>
|
||||
<path d="m74 111-6 25 8 11 6-15 10-24m75 0 16 28-5 11-4-17-15-19" fill="var(--skin-shade)" stroke="none"/>
|
||||
${wristbands ? '<path d="m71 143 18-2 4 15-20 4Zm107-3 19 1-3 18-20-5Z" fill="var(--trim)"/>' : ''}
|
||||
<path d="m74 157-1 14 10 8 14-7-2-16-12-3Zm102-3-4 16 12 9 12-6 1-14-12-7Z" fill="var(--skin)"/>
|
||||
<path d="${wristbands ? 'm78 159 0 8m6-10 0 9m5-9 0 8m89-7-1 8m7-6-1 8m6-5-1 7m-114-28 14-2m96 1 13 3' : 'm78 159 0 8m6-10 0 9m5-9 0 8m89-7-1 8m7-6-1 8m6-5-1 7'}" stroke-width="1.1"/>
|
||||
</g>
|
||||
<g class="pose pose-attack">
|
||||
<path d="m86 91-10 18 19 27 35 8 46-18-7-17-42 13-19-14-7-18Z" fill="var(--skin)"/>
|
||||
<path d="m84 112 20 18 24 7 40-17 1 7-37 17-37-8Z" fill="var(--skin-shade)" stroke="none"/>
|
||||
<path d="m156 89 25 12 29-2 6 20-38 6-23-12-9-15Z" fill="var(--skin)"/><path d="m167 111 17 7 25-6 3 7-34 6-17-7Z" fill="var(--skin-shade)" stroke="none"/>
|
||||
${wristbands ? '<path d="m195 100 18-4 7 22-21 4Zm-36 12 12-4 8 17-13 7Z" fill="var(--trim)"/>' : ''}
|
||||
<path d="m211 100 6-13 5-5 5 2-4 15 21-10 5 4-16 12 17-5 3 5-17 10 13-1 1 5-25 8-12-6Z" fill="var(--skin)"/>
|
||||
<path d="m172 115 12-10 9-1 2 5-12 9 14-4 3 5-20 12-9-6Z" fill="var(--skin)"/>
|
||||
<path d="${wristbands ? 'm197 104 17-4m-16 9 17-5m-16 10 18-5m-107 11 11 5m58-19 12-1m30 8 6-9' : 'm110 120 11 5m58-19 12-1m30 8 6-9'}" stroke-width="1.1"/>
|
||||
</g>
|
||||
<g class="pose pose-guard">
|
||||
<path d="m87 92-10 14 19 35 26-6 29-52-15-11-29 43-9-23Zm74 0 16 18-15 34-24-9-31-48 14-14 30 40 3-13Z" fill="var(--skin)"/>
|
||||
<path d="m83 107 20 25 14-2 5 6-26 5Zm78-6 9 13-12 21-7-7Z" fill="var(--skin-shade)" stroke="none"/>
|
||||
${wristbands ? '<path d="m121 91 14-23 20 12-15 23Zm-16-13 15-11 17 24-17 13Z" fill="var(--trim)"/>' : ''}
|
||||
<path d="m134 72 0-13 8-9 14 4 3 12-8 14Zm-31 5-6-11 4-13 15-3 8 12-5 11Z" fill="var(--skin)"/>
|
||||
<path d="${wristbands ? 'm141 57 0 9m5-10 0 8m-42-7 4 8m2-11 3 8m-3 17 12-7m-9 11 12-7m11 5 15 8m-17-4 13 7' : 'm141 57 0 9m5-10 0 8m-42-7 4 8m2-11 3 8'}" stroke-width="1"/>
|
||||
</g>
|
||||
<g class="pose pose-dodge">
|
||||
<path d="m88 91-18 14-22 24 11 14 27-15 14-25Z" fill="var(--skin)"/><path d="m76 109-20 23 5 5 23-15 8-18" fill="var(--skin-shade)" stroke="none"/>
|
||||
${wristbands ? '<path d="m48 125 15 14-9 13-17-14Z" fill="var(--trim)"/>' : ''}<path d="m38 136-13 4-3 12 12 9 12-4 8-10Z" fill="var(--skin)"/>
|
||||
<path d="m159 90 16 9 7-17-20-24-10 10 11 17-8 14Z" fill="var(--skin)"/><path d="m162 99 12 5 8-22-8-1-4 13Z" fill="var(--skin-shade)" stroke="none"/>
|
||||
${wristbands ? '<path d="m153 59 10-7 11 14-12 9Z" fill="var(--trim)"/>' : ''}<path d="m154 62-8-10-5-14 5-2 7 13 1-18 5 0 1 23 4 4-3 8Z" fill="var(--skin)"/>
|
||||
<path d="${wristbands ? 'm156 62 10-6m-7 10 9-6m-137 83 5 6m0-9 6 6' : 'm31 143 5 6m0-9 6 6'}" stroke-width="1.1"/>
|
||||
</g>
|
||||
<g class="pose pose-win">
|
||||
<path d="m88 92-14 15-8 38 16 7 13-41 5-9Zm72-2 18 11 14-20-10-8-16 11Z" fill="var(--skin)"/>
|
||||
<path d="m76 111-7 31 8 3 9-31m82-24 12 6 6-8" fill="var(--skin-shade)" stroke="none"/>
|
||||
${wristbands ? '<path d="m67 136 20 6-4 16-20-6Zm108-58 10-15 17 11-12 16Z" fill="var(--trim)"/>' : ''}
|
||||
<path d="m65 151-2 14 12 8 12-9-5-12Zm118-86 1-15 12-5 11 8-3 13-11 8Z" fill="var(--skin)"/>
|
||||
<path d="m189 53v8m6-10v9m5-6v8m-130 94v8m6-6v7" stroke-width="1.1"/>
|
||||
</g>
|
||||
<g class="pose pose-loss">
|
||||
<path d="m86 94-13 23-5 41 15 5 14-50Zm74-3 18 15 9 35-15 16-38-14 4-14 30 8-12-29Z" fill="var(--skin)"/>
|
||||
<path d="m78 115-6 36 7 4 9-40m77 29 11-3 8-3-10 15-34-12 1-6Z" fill="var(--skin-shade)" stroke="none"/>
|
||||
${wristbands ? '<path d="m68 150 18 4-3 15-17-4Zm63-23 17 5-7 17-14-6Z" fill="var(--trim)"/>' : ''}
|
||||
<path d="m67 164-2 15 8 8 11-5 0-13Zm65-35-12-6-16 3-3 7 8 9 19 2Z" fill="var(--skin)"/>
|
||||
<path d="m108 130 13 1m-12 5 10 1m-49 35v8m6-7v8" stroke-width="1.1"/>
|
||||
</g>
|
||||
<g class="pose pose-draw">
|
||||
<path d="m86 92-16 24 13 23 58-6-1-16-47 4 8-17Zm75 0 17 20-7 33-54-6 1-17 37 1-6-20Z" fill="var(--skin)"/>
|
||||
<path d="m76 117 11 14 43-3 1 5-48 6Zm79 15 13-7-2 15-41-4 1-8Z" fill="var(--skin-shade)" stroke="none"/>
|
||||
${wristbands ? '<path d="m121 117 20 0 3 17-20 1Zm-16 3 20 3-2 18-20-4Z" fill="var(--trim)"/>' : ''}
|
||||
<path d="m140 119 12-4 9 7-5 10-12 0Zm-35 1-11-4-10 5 4 12 15 3Z" fill="var(--skin)"/>
|
||||
${wristbands ? '<path d="m126 122h13m-12 6h13m-32-2 14 3m-15 2 13 3" stroke-width="1"/>' : ''}
|
||||
</g>
|
||||
<g class="pose pose-hit">
|
||||
<path d="m88 92-15-7-25-24-12 12 18 33 24 12 22-13Zm72 0 19-8 22 23 28-9 7 14-30 16-20-5-31-15Z" fill="var(--skin)"/>
|
||||
<path d="m46 75 15 24 22 7-5 10-24-10Zm132 18 19 22 27-8 5 6-23 15-20-5Z" fill="var(--skin-shade)" stroke="none"/>
|
||||
${wristbands ? '<path d="m34 66 12-15 16 14-15 18Zm185 32 12-5 9 22-15 6Z" fill="var(--trim)"/>' : ''}
|
||||
<path d="m37 58-10-3-13-15 4-4 13 11-7-18 5-3 11 21-2-21 5 0 7 27-4 9Zm194 36 8-13 5 2-4 16 12-5 3 5-11 13-6 5Z" fill="var(--skin)"/>
|
||||
${wristbands ? '<path d="m42 64 9-9m-5 13 9-9m169 45 11-5m-9 11 12-6" stroke-width="1.1"/>' : ''}
|
||||
</g>
|
||||
</g>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
/** Preserved Yamcha-reference variant; not used by the active finisher. */
|
||||
export function yamchaFallenFighterArt(index: number) {
|
||||
return `<svg class="fallen-fighter-art" data-fallen-index="${index}" viewBox="0 0 640 400" fill="none" aria-hidden="true" style="${fighterPalette(index)}">
|
||||
<g stroke="#343731" stroke-linejoin="round" stroke-linecap="round">
|
||||
<path d="m42 212 42-85 95-64 136-25 123 23 102 61 57 99-39 91-115 61-134 12-150-38-86-64Z" fill="#b9af93" stroke-width="3"/>
|
||||
<path d="m78 210 43-64 74-48 117-24 115 27 83 49 47 70-38 66-88 48-121 15-121-33-83-57Z" fill="#8c8974" stroke-width="2.5"/>
|
||||
<path d="m125 213 42-50 79-33 89-15 94 34 71 54-10 66-78 42-102 7-98-27-69-38Z" fill="#777c68" stroke-width="2"/>
|
||||
<g stroke-width="2">
|
||||
<path d="m51 214 59-6 51 18 38-13m-99-73 31 25 40-3 33 23m-28-111 22 48 30 6m77-79-6 47 19 25m75-68-15 46 19 29m87-16-33 21-13 38m91-18-33 39-36 2m67 59-52-16-35 15m13 70-28-40-21-3m-38 69-4-35-31-27m-61 65 8-37-23-19m-92 29 31-37-9-29m-79 26 40-34 30-2"/>
|
||||
<path d="m87 91 12-8 17 8-6 13-21-1ZM522 85l14-8 20 13-10 14-20-3ZM50 292l21-8 15 19-27 7ZM498 344l20-15 23 8-2 21-29 4ZM130 355l19-23 27-2 19 16-7 16-34 7Z" fill="#c4b89c"/>
|
||||
<path d="m146 337 12 14 25-7m-26 9-3 14m-92-75 9 9m465-213 9 8m-32 248 11 8" stroke-width="1.3"/>
|
||||
</g>
|
||||
<g fill="#dbd1b7" stroke="#747664" stroke-width="1.5">
|
||||
<path d="M145 87q-13-7-6-18-4-15 10-17 6-13 17-5 15-2 14 14 9 12-5 19-8 14-20 5Z"/>
|
||||
<path d="M482 123q-10-6-5-16-2-10 9-12 6-10 14-3 12 2 8 13 8 11-4 16-11 12-22 2Z"/>
|
||||
<path d="M457 62q-7-7 0-13 7-5 12 1 6 9-4 14Z"/>
|
||||
</g>
|
||||
<g stroke="#525b4c" stroke-width="1.3">
|
||||
<path d="m173 206-36-5m45 13-42-3m38 13-30-1m29 12-28 3m325-60 28 4m-26 4 35 4m-33 4 27 4m-25 52 31 9m-36-1 24 9m-232 42-24 12m32-6-23 13m111-226-11-15m22 9-4-18"/>
|
||||
</g>
|
||||
<g class="fallen-pose-trace" transform="translate(130 -100) scale(1.2)" fill="none" stroke="#242b2b" stroke-width="1.6" stroke-linejoin="round" stroke-linecap="round">
|
||||
<path d="M49 291 64 272 86 267 112 242 165 235 196 256 206 291 218 328 215 356 199 359 183 338 149 331 128 321 111 334 96 327 94 308 76 304 52 301Z" fill="#333c30" stroke="none" opacity=".55"/>
|
||||
<!-- The hidden right arm terminates here; its shoulder has no visible contour. -->
|
||||
<path d="M112 259 102 277 88 290 75 290 72 279 87 272 98 256Z" fill="var(--skin-shade)"/>
|
||||
<path d="m86 271 13 4-7 15-14 5-6-10Z" fill="var(--trim)"/>
|
||||
<path d="m82 278 10 3m-12 2 10 3" stroke-width="1"/>
|
||||
<path d="M77 281 67 277 55 280 46 290 49 297 63 299 74 296 81 289Z" fill="var(--skin)"/>
|
||||
<path d="m54 283 3 6m3-9 3 6m3-7 3 6m-5 7 8-3" stroke-width="1.1"/>
|
||||
<path d="m49 293 13 2 10-4 9-2-7 7-11 3-14-2Z" fill="var(--skin-shade)" stroke="none"/>
|
||||
<!-- The far thigh disappears beneath the abdomen and the central forward sole. -->
|
||||
<path d="M130 252 114 257 108 269 106 281 98 294 100 307 114 313 130 299 147 284 159 269Z" fill="var(--suit)"/>
|
||||
<path d="M119 262 115 277 120 286 111 300 102 302 100 307 114 313 136 293 147 284 138 273Z" fill="var(--suit-dark)" stroke="none"/>
|
||||
<path d="m111 269 6-7m-9 18 7-8m3 3 5-11m-20 29 7-5" stroke-width="1.2"/>
|
||||
<!-- A low back and pelvis share one contour; the knee interrupts its lower edge. -->
|
||||
<path d="M118 226 130 219 146 219 162 222 177 225 188 231 195 241 199 255 202 269 199 280 191 289 179 296 163 295 147 288 136 275 128 264 119 259 111 249Z" fill="var(--suit)"/>
|
||||
<path d="M180 228 190 239 194 254 195 268 188 279 173 284 151 281 159 289 179 296 191 289 199 280 202 269 199 255 195 241 188 231Z" fill="var(--suit-dark)" stroke="none"/>
|
||||
${index === 1
|
||||
? '<path d="M124 229 136 223 148 226 139 240 143 261 133 253 124 241Z" fill="var(--suit-dark)" stroke="none"/><path d="M169 226 182 230 191 241 187 253 174 242 163 236Z" fill="var(--trim)"/><path d="m172 234 12 8m-11-3 10 7" stroke="var(--suit-dark)" stroke-width="1.5"/>'
|
||||
: '<path d="M119 228 131 222 150 223 170 226 185 233 182 237 167 232 149 229 134 229 124 235Z" fill="var(--trim)"/><path d="m139 225 10 1m17 3 12 5" stroke-width="1"/>'}
|
||||
<path d="m140 238 8 16 11 9m22-15 1 13-12 9-12 1m-17-8 7 11m39-4 9-4" stroke-width="1.3"/>
|
||||
<!-- Short rear hair retains the tucked head position and never exposes the face. -->
|
||||
<path d="M111 218 122 215 139 217 147 223 138 231 121 233 111 228Z" fill="var(--skin-shade)"/>
|
||||
${index === 1
|
||||
? '<path d="M76 236 77 224 71 214 84 216 93 202 101 211 119 204 116 213 135 213 130 222 139 226 128 236 111 238 97 242 85 240Z" fill="var(--hair)"/><path d="M116 216 130 218 128 231 113 236 101 237 110 227Z" fill="var(--hair-shadow)" stroke="none"/><path d="m82 222 13-7 12 3-6 8m5-9 11-5m-26 16 10-6" stroke="var(--hair-shadow)" stroke-width="1.4"/>'
|
||||
: '<path d="M75 237 78 225 70 217 84 218 83 206 97 212 107 198 111 211 128 204 123 215 138 213 132 223 140 227 127 237 111 239 96 243 83 241Z" fill="var(--hair)"/><path d="M98 215 107 208 106 222 117 216 112 229 125 223 119 234 106 237 91 239 99 227 86 231Z" fill="var(--hair-shadow)" stroke="none"/><path d="M77 233 92 229 109 225 132 222 134 226 111 229 94 233 79 237Z" fill="#864237"/><path d="M80 234 68 234 63 230 67 239 78 238 72 245 83 239Z" fill="#864237" stroke-width="1.2"/>'}
|
||||
<!-- This broad bent arm masks the face and cuts off the lower hair contour. -->
|
||||
<path d="M122 226 112 230 100 232 86 234 75 238 68 244 64 251 62 259 68 265 78 268 89 268 104 262 118 254 126 247 132 234Z" fill="var(--skin)"/>
|
||||
<path d="M63 256 68 260 80 262 91 261 107 254 119 244 125 233 132 234 126 247 118 254 104 262 89 268 78 268 68 265 62 259Z" fill="var(--skin-shade)" stroke="none"/>
|
||||
<path d="M119 226 131 222 142 225 148 231 137 237 127 247 119 245 126 235Z" fill="var(--suit)"/>
|
||||
<path d="M119 226 124 225 130 235 123 245 119 245 126 235Z" fill="${index === 1 ? 'var(--suit-dark)' : 'var(--trim)'}"/>
|
||||
<path d="m109 248-9 7-10 3m-14-12-3 8m52-24 6 3" stroke-width="1.2"/>
|
||||
<path d="M72 233 76 228 82 228 88 232 86 235 77 239 70 241Z" fill="var(--trim)"/>
|
||||
<path d="m75 231 5 5m-8-2 5 4" stroke-width="1"/>
|
||||
<!-- The right boot is a narrow side view behind the folded right knee. -->
|
||||
<path d="M188 309 201 312 209 318 213 330 206 348 196 350 188 337 184 321Z" fill="#30383c"/>
|
||||
<path d="m190 314 9 2 6 6m-16-3 9 2 6 6" stroke="var(--trim)" stroke-width="2.5"/>
|
||||
<path d="M208 315 213 319 216 326 217 339 212 352 206 356 197 355 194 346 195 334 200 323Z" fill="#30383c"/>
|
||||
<path d="M207 320 201 330 198 341 200 350 205 352 211 339 211 330Z" fill="#454e53" stroke="none"/>
|
||||
<path d="M209 319 213 327 214 338 209 350 205 353 199 352" stroke="#b5b4a5" stroke-width="1.5"/>
|
||||
<path d="m202 330 7 5m-10 7 7 4" stroke-width="1.2"/>
|
||||
<!-- Folded thighs overlap the abdomen without an elongated exposed shin. -->
|
||||
<path d="M148 284q11-7 25-4l13 5 7 8-3 8 7 5 2 11-5 10-9 4-9 7-5-6-4 5-6-8-10-3-9-8-6-9-1-12Z" fill="var(--suit)"/>
|
||||
<path d="M146 295 153 287 163 284 177 286 187 291 174 289 162 290 154 297 147 311 151 319 159 324 171 329 171 334 167 337 161 329 151 326 142 318 136 309 135 297Z" fill="var(--suit-dark)" stroke="none"/>
|
||||
<path d="m154 299 8-5 12 0 13 3m-34 13 10-5 10 2m-14 15 15 6 15-8" stroke-width="1.3"/>
|
||||
<!-- The large central sole overlaps the folded leg and the knee's left edge. -->
|
||||
<path d="M123 277 135 279 143 287 145 297 137 304 125 313 110 316 106 307 113 291Z" fill="#30383c"/>
|
||||
<path d="m122 280 9 2 10 10m-21-8 9 2 10 10" stroke="var(--trim)" stroke-width="2.5"/>
|
||||
<path d="M131 283 137 283 141 289 142 296 135 307 126 316 118 328 112 331 104 328 100 322 102 312 110 300 120 288Z" fill="#30383c"/>
|
||||
<path d="M130 287 136 287 139 292 137 299 128 308 120 319 114 327 108 327 103 322 105 313 113 301 122 291Z" fill="#454e53" stroke="none"/>
|
||||
<path d="M132 286 137 289 139 295 132 305 123 314 115 326 111 328 105 325 103 321" stroke="#b5b4a5" stroke-width="1.5"/>
|
||||
<path d="m122 295 10 10m-17 0 9 10m-16 0 8 8" stroke-width="1.2"/>
|
||||
</g>
|
||||
<g stroke="#4a5547" stroke-width="1.2">
|
||||
<path d="m181 192 14 2m-19 4 16 2m-18 5 15 2m120 119 12 3m-16 3 15 4m-18 3 14 3m126-99 22 3m-20 3 25 4m-23 3 17 4m-27 83 20 3m-21 4 16 3"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
/** Slumped against a rock face, with loose arms and legs extending along the ground. */
|
||||
function fallenFighterArt(index: number, appearance?: FighterAppearance) {
|
||||
const characterId = appearance?.characterId ?? index;
|
||||
const shoes = equipped(appearance, 'shoes'), wristbands = equipped(appearance, 'wristbands');
|
||||
return `<svg class="fallen-fighter-art rock-fallen-art" data-fallen-index="${index}" viewBox="0 0 640 400" fill="none" aria-hidden="true" style="${fighterPalette(index, appearance)}">
|
||||
<g stroke="#343731" stroke-width="2" stroke-linejoin="round" stroke-linecap="round">
|
||||
<path d="M-1200 276 100 272 201 278 294 273 419 282 1800 270V400H-1200Z" fill="#c4b89a" stroke="none"/>
|
||||
<path d="M0 276 100 272 201 278 294 273 419 282M29 321l151-17 90 1m-192 38 145-22 57-1m-151 40 126-27" stroke="#8c8771" stroke-width="1.3"/>
|
||||
<!-- A tall rock face supports the shoulder; the open ground runs off to the left. -->
|
||||
<path d="M393 287 422 241 438 171 456 116 473 84 487 23 524-14 579-9 602 61 630 103 658 129 790 20 960 64 1150-60 1800-80V321L740 323 638 310 538 305 468 319Z" fill="#a59f86" stroke-width="3"/>
|
||||
<path d="m438 171 18-55 17-32 14-61 37-37 12 91-36 77-15 72-28 52-64 9 29-46Z" fill="#c8bea1" stroke="none"/>
|
||||
<path d="M536 77 579-9 602 61 630 103 594 143 607 222 680 281 790 20 745 211 850 298 960 64 950 278 1150-60 1110 300 1800 321 740 323 638 310 538 305 468 319 495 273 498 209 524 155Z" fill="#858571" stroke="none"/>
|
||||
<path d="m487 23 25 52-26 64 14 15m36-77-12 78-26 54-3 64-27 46m126-176-32 26 9 64-33 72m-92-134 21 30-13 43m59-107 15 37-12 34m-35 65 14 11 23-5m40-180 8 26-10 34"/>
|
||||
<path d="m432 190 22-15 10-27m-23 66 26-8 15 13m-46 14 15 18-4 24m-11-65 8-16 13-2" stroke="#595e50" stroke-width="1.5"/>
|
||||
<path d="m447 125 8-21m-2 37 9-26m25 64 6-18m-10 31 6-19m64 64 9-22m-7 39 9-24m-14 39 8-17" stroke="#686e5c" stroke-width="1.2"/>
|
||||
<path d="M155 296q99-27 231-24l62 19-41 27-108 33-72-3 38-25-110-12Z" fill="#4a5143" stroke="none" opacity=".3"/>
|
||||
<g stroke="#242b2b" stroke-width="2.5">
|
||||
<!-- Far arm hangs alongside the rock instead of supporting the body. -->
|
||||
<path d="m412 181 15 6 11 35-5 30-8 22-11-3 6-34-13-30Z" fill="var(--skin)"/>
|
||||
<path d="m423 195 9 28-6 30-6 14 5 7 8-22 5-30-11-35Z" fill="var(--skin-shade)" stroke="none"/>
|
||||
${wristbands ? '<path d="m416 259 13 4-5 15-13-4Z" fill="var(--trim)"/>' : ''}
|
||||
<path d="m412 274 12 3-3 13-6 6-4-3 2-9-7 7-4-4 4-8Z" fill="var(--skin)"/>
|
||||
<!-- The far leg lies almost horizontally; its shoe is seen from the side. -->
|
||||
<path d="M377 244 391 260 361 281 313 285 264 289 216 287 213 269 264 268 307 265 348 250Z" fill="var(--suit)"/>
|
||||
<path d="m217 280 46 2 49-5 45-5 28-17 6 5-30 21-48 4-49 4-48-2Z" fill="var(--suit-dark)" stroke="none"/>
|
||||
<path d="m314 269-15 9m-29-5-12 7m81-15 15-6" stroke-width="1.5"/>
|
||||
${shoes ? `<path d="M214 270 216 286 199 289 197 273Z" fill="var(--skin)"/>
|
||||
<path d="M199 273Q204 281 201 289L170 292Q158 293 155 287Q153 282 161 279L184 276Z" fill="#30383c"/>
|
||||
<path d="M158 288Q164 290 172 289L200 286" stroke-width="1"/>` : `<g class="bare-feet">
|
||||
<path d="M214 270 216 286 199 289 175 292Q160 294 157 289Q153 285 159 282L182 278Q191 277 198 273Z" fill="var(--skin)"/>
|
||||
<path d="M195 281Q198 284 200 283M161 283l2 6m5-7 2 7m5-8 2 7" stroke-width="1"/>
|
||||
</g>`}
|
||||
<!-- The pelvis rests on the ground while the upper back leans into the rock. -->
|
||||
<path d="M371 180 389 169 409 174 426 188 419 218 404 252 381 267 353 249 362 216 359 194Z" fill="var(--suit)"/>
|
||||
<path d="m409 177 11 15-13 39-26 20-21-4-7 2 28 18 23-15 15-34 7-30Z" fill="var(--suit-dark)" stroke="none"/>
|
||||
<path d="M377 165 393 165 400 179 391 193 375 182Z" fill="var(--skin)"/>
|
||||
<path d="m377 169 15 3 6 8-7 9-12-10Z" fill="var(--skin-shade)" stroke="none"/>
|
||||
${characterId === 1
|
||||
? '<path d="m363 190 8-10 7 13-10 15-8-6Zm44-14 14 8 5 10-13 10-11-8Z" fill="var(--trim)"/>'
|
||||
: '<path d="m370 181 6-5 13 16 14-16 7 4-20 22Z" fill="var(--trim)"/>'}
|
||||
<path d="m375 214 12 11 13-5m-35 15 18 4-9 9m29-44 8-9" stroke-width="1.5"/>
|
||||
<!-- Knee, shin and shoe share one diagonal instead of turning outward at the ankle. -->
|
||||
<path d="M377 248 397 258 374 291 351 313 325 345 304 334 327 298 346 273 356 253Z" fill="var(--suit)"/>
|
||||
<path d="m381 258-18 29-27 26-21 26 10 6 26-32 23-22 23-33-10-5Z" fill="var(--suit-dark)" stroke="none"/>
|
||||
<path d="m349 279 12-6m-30 33 13-13m-14 32 9-13m28-49 9 5" stroke-width="1.5"/>
|
||||
${shoes ? `<path d="M305 335 324 345 315 359 295 347Z" fill="var(--skin)"/>
|
||||
<path d="M296 348Q302 356 316 358L305 374Q301 380 286 384Q280 386 274 379Q272 376 277 370Z" fill="#30383c"/>
|
||||
<path d="M276 378Q280 382 286 381L300 375 312 361" stroke-width="1"/>` : `<g class="bare-feet">
|
||||
<path d="M305 335 324 345 313 361 303 373Q296 379 285 383Q278 386 274 381Q271 378 276 372L294 351Z" fill="var(--skin)"/>
|
||||
<path d="M296 350Q300 355 305 355M277 374l5 5m1-10 6 7m0-12 6 8" stroke-width="1"/>
|
||||
</g>`}
|
||||
<!-- Right arm follows the user's short, slack outline with the same width as the far arm. -->
|
||||
<path d="M351 201 364 209 351 227 337 236 323 247 311 241 327 226 338 215Z" fill="var(--skin)"/>
|
||||
<path d="M356 209 346 223 333 234 317 244 323 247 337 236 351 227 364 209 359 206Z" fill="var(--skin-shade)" stroke="none"/>
|
||||
<path d="M372 182Q361 184 356 193L350 203 362 211 376 197 380 188Z" fill="var(--suit)"/>
|
||||
<path d="M350 201 363 207 374 196 376 199 363 212 348 205Z" fill="var(--trim)"/>
|
||||
${wristbands ? '<path d="m317 230 13 10-7 7-13-10Z" fill="var(--trim)"/>' : ''}
|
||||
<path d="m311 239 12 7-5 6-12 0-5-3 1-4 5-2Z" fill="var(--skin)"/>
|
||||
<path d="m312 247-5 2m10 0-5 2m35-35-6 8" stroke-width="1.2"/>
|
||||
<g class="rock-fallen-head" transform="translate(384 150) rotate(16) scale(.86) translate(-130 -52)">${fighterHead(characterId, false, appearance)}</g>
|
||||
</g>
|
||||
<g fill="#b4ac90" stroke="#676d5b" stroke-width="1.5">
|
||||
<path d="m438 288 16-11 20 13-11 13-22-3Zm-318 28 12-5 9 8-7 6-15-2Zm258 37 12-9 16 5-3 11-20 2Z"/>
|
||||
<path d="m454 279 2 12 17-1m-341 25 1 8"/>
|
||||
</g>
|
||||
<path d="m95 299 45-4m-23 38 37-6m20-21 19-2m173 24 18-4m-7 46 47-4m-54 14 28-3" stroke="#767b65" stroke-width="1.2"/>
|
||||
<path d="M82 298q17-10 33-2m-2 8q22-9 39-4m29 25q20-9 41-5" stroke="#e8dcc0" stroke-width="7" opacity=".7"/>
|
||||
</g>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
function beamFinisherArt(hitIndex: 0 | 1, appearances?: [FighterAppearance, FighterAppearance]) {
|
||||
const attacker = 1 - hitIndex;
|
||||
const victim = appearances?.[hitIndex];
|
||||
const maskId = `beam-remnant-${hitIndex}`;
|
||||
const reactionMaskId = `beam-reaction-${hitIndex}`;
|
||||
return `<div class="cutscene-page cutscene-beam-page" aria-hidden="true">
|
||||
<div class="cutscene-panel cutscene-attack" data-panel="attack">
|
||||
<div class="cutscene-ink-strokes"></div>
|
||||
<div class="fighter cutscene-closeup" data-fighter="${attacker}" data-action="beam"><div class="character-direction">${fighterArt(attacker, true, appearances?.[attacker])}</div></div>
|
||||
</div>
|
||||
<div class="cutscene-panel cutscene-impact beam-impact" data-panel="impact">
|
||||
<div class="beam-rush-lines"></div>
|
||||
<div class="fighter beam-impact-victim" data-fighter="${hitIndex}" data-hit="true"><div class="character-direction">${fighterArt(hitIndex, false, appearances?.[hitIndex])}</div></div>
|
||||
<div class="beam-launch" data-caster="${attacker}">
|
||||
<div class="fighter beam-caster" data-fighter="${attacker}" data-action="beam"><div class="character-direction">${fighterArt(attacker, false, appearances?.[attacker])}</div></div>
|
||||
<svg class="beam-ray" viewBox="0 0 260 300" fill="none" aria-hidden="true">
|
||||
<path d="M182 122Q195 103 225 99L5000-3400V3800L213 138Q191 140 182 122Z" fill="#89d7e6" stroke="#477d90" stroke-width="2"/>
|
||||
<path d="M193 119 225 103 5000-3100V3500L215 134Z" fill="#d4faff"/>
|
||||
<path d="M204 116 228 109 5000-2470V2760L220 128Z" fill="#fcffff"/>
|
||||
<path d="m218 108 193-120-164 117 380-197m-411 222 222 151-192-145 376 206" stroke="#f4ffff" stroke-width="4"/>
|
||||
<path d="m236 103 154-85m-149 114 212 129m-177-150 340-159m-350 176 446 218" stroke="#79c9df" stroke-width="2"/>
|
||||
<path d="M184 122q12-20 36-19m-28 28q12 8 27 1" stroke="#f7ffff" stroke-width="5"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cutscene-panel cutscene-fallen beam-aftermath" data-panel="fallen">
|
||||
<div class="beam-reaction" data-panel="reaction">
|
||||
<div class="beam-shock-lines"></div>
|
||||
<div class="fighter beam-reaction-portrait" data-fighter="${hitIndex}" data-hit="true">
|
||||
<svg class="beam-reaction-art" viewBox="0 0 136 130" fill="none" aria-hidden="true" style="${fighterPalette(hitIndex, appearances?.[hitIndex])}">
|
||||
<defs><mask id="${reactionMaskId}" maskUnits="userSpaceOnUse" x="0" y="0" width="136" height="130">
|
||||
<rect width="136" height="130" fill="white"/>
|
||||
<path d="M0 0H28L20 12 30 19 22 25 28 32 19 42 28 46 23 56 32 61 24 72 32 80 22 92 29 99 16 109 25 119 17 130H0Z" fill="black"/>
|
||||
<path class="beam-reaction-erosion" d="M85 0 79 12 90 20 77 26 89 34 73 42 83 50 72 57 79 68 68 77 78 85 63 97 75 112 58 130H136V0Z" fill="black"/>
|
||||
<path d="m26 47 11-5-6 7 7 5-10-3Zm48 18-10 6 6 3-9 8 4-11Zm-43 37 14-7-7 8 3 6-10-3Z" fill="black"/>
|
||||
</mask></defs>
|
||||
<g class="beam-reaction-remnant" mask="url(#${reactionMaskId})">${fighterArt(hitIndex, true, appearances?.[hitIndex])}</g>
|
||||
<g class="beam-reaction-light" stroke="#c1f5fa" stroke-width=".8" stroke-linejoin="round">
|
||||
<path d="m84 1-3 11 11 8-12 6 11 8-15 8 10 8-11 7 7 11-11 9 10 8-15 12 12 15-17 18h8l17-19-11-14 13-12-9-10 9-8-8-10 10-8-9-8 14-7-11-9 11-6-11-9 4-10Z" fill="#fcffff"/>
|
||||
<path d="m28 30-6 12 8 4-4 11 9 4-6 11 6 7-7-3 3-13-9-5 3-11-8-4 8-13Zm44 31-8 10 6 3-10 11 6-12-6-2Z" fill="#fcffff"/>
|
||||
<path d="m88 30 8-5-3 6Zm-3 31 6-3-2 5Zm-8 33 7-4-3 6Z" fill="var(--skin)" stroke="none"/>
|
||||
<path d="m87 13 7-4-2 5Zm9 4 4-2-1 3Z" fill="var(--${victim?.hairStyle === 'bald' ? 'skin' : 'hair'})" stroke="none"/>
|
||||
<path d="m81 106 7-3-3 5Zm-12 17 9-4-4 5Z" fill="var(--suit)" stroke="none"/>
|
||||
<path d="m94 43 18-9m-24 40 22-11m-37 51 27-15m-77-32 12-7" fill="none"/>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="beam-dissolve" data-panel="dissolve">
|
||||
<div class="beam-dissolve-lines"></div>
|
||||
<svg class="beam-dissolve-art" viewBox="0 0 260 300" fill="none" aria-hidden="true" style="${fighterPalette(hitIndex, appearances?.[hitIndex])}">
|
||||
<defs><mask id="${maskId}" maskUnits="userSpaceOnUse" x="0" y="0" width="260" height="300">
|
||||
<rect width="260" height="300" fill="black"/>
|
||||
<path d="m90 27 21-8-8 10 12 4-16 9-13-3 9-5Zm23 18 19-7-6 10-13 4Zm-10 31 13-9-4 12 8 6-16 5-7-7Z" fill="white"/>
|
||||
<path d="m117 110 16-8-5 13-13 5Zm-29 23 12-6-4 9-10 3Zm31 32 18-9-7 11-12 4Zm-4 30 10-6-4 8Z" fill="#888"/>
|
||||
</mask></defs>
|
||||
<g class="fighter beam-remnant" data-fighter="${hitIndex}" data-hit="true" mask="url(#${maskId})">${fighterArt(hitIndex, false, appearances?.[hitIndex])}</g>
|
||||
<g class="beam-vanish-wake" stroke="#81b3bb" stroke-linecap="round">
|
||||
<path d="M59 63q30-19 68-19m-60 30 29-12m10-20 48-13M72 113q27-22 57-23m-49 31 26-13m20-22 49-13M82 166q23-17 47-19m-36 26 27-14m9-20 43-12M94 214l35-16m-19 27 32-19" stroke-width="1.4"/>
|
||||
<path d="m54 76 18-8m-9 49 17-7m-4 62 16-8m-5 63 24-13m24-165 28-11m-25 66 44-17m-45 77 28-12" stroke-width=".7"/>
|
||||
</g>
|
||||
<g class="beam-fragments" stroke="none">
|
||||
<path d="m118 35 7-4-3 5Zm16-10 5-2-2 3Zm-28 27 5-3-1 3Z" fill="var(--${victim?.hairStyle === 'bald' ? 'skin' : 'hair'})"/>
|
||||
<path d="m124 73 7-3-4 5Zm-14 16 5-2-2 3Z" fill="var(--skin)"/>
|
||||
<path d="m134 111 8-4-4 5Zm-17 20 5-3-2 4Zm17 28 7-3-3 4Zm-10 28 5-2-2 3Z" fill="var(--suit)"/>
|
||||
<path d="m128 225 5-3-2 4Zm13-15 4-2-1 2Z" fill="${equipped(victim, 'shoes') ? '#30383c' : 'var(--skin)'}"/>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
export function finisherArt(hitIndex: 0 | 1, attack: ActionId, appearances?: [FighterAppearance, FighterAppearance]) {
|
||||
if (attack === 'beam') return beamFinisherArt(hitIndex, appearances);
|
||||
const attacker = 1 - hitIndex;
|
||||
const speedLines = Array.from({ length: 44 }, (_, i) => {
|
||||
const angle = i * Math.PI / 22, inner = 85 + (i % 4) * 21;
|
||||
const x = (r: number) => (400 + Math.cos(angle) * r).toFixed(1);
|
||||
const y = (r: number) => (170 + Math.sin(angle) * r * .65).toFixed(1);
|
||||
return `<path d="M${x(inner)} ${y(inner)} ${x(740)} ${y(740)}" stroke-width="${i % 3 === 0 ? 5 : 1.5}"/>`;
|
||||
}).join('');
|
||||
return `<div class="cutscene-page" aria-hidden="true">
|
||||
<div class="cutscene-panel cutscene-attack" data-panel="attack">
|
||||
<div class="cutscene-ink-strokes"></div>
|
||||
<div class="fighter cutscene-closeup" data-fighter="${attacker}" data-action="${attack}"><div class="character-direction">${fighterArt(attacker, true, appearances?.[attacker])}</div></div>
|
||||
</div>
|
||||
<div class="cutscene-panel cutscene-impact" data-panel="impact">
|
||||
<svg class="cutscene-speedlines" viewBox="0 0 800 340" preserveAspectRatio="none" fill="none"><g stroke="#292d2b">${speedLines}</g></svg>
|
||||
${[0, 1].map(index => `<div class="fighter cutscene-combatant" data-fighter="${index}" data-hit="${index === hitIndex}"${index === attacker ? ` data-action="${attack}"` : ''}><div class="character-direction">${fighterArt(index, false, appearances?.[index])}</div></div>`).join('')}
|
||||
<div class="cutscene-shot"></div>
|
||||
<svg class="cutscene-contact" viewBox="0 0 240 240"><path d="m122 6 11 62 46-50-22 64 73-19-51 45 55 19-63 10 40 61-62-29-18 66-17-62-67 49 33-66-71-3 58-38L12 57l69 28-3-68 31 59Z" fill="#f6eed2" stroke="#28292a" stroke-width="3"/><path d="m119 55 10 42 38-24-22 40 42 11-43 14 15 33-35-24-20 42-2-47-39 8 30-26-29-26 43 11Z" fill="#a84a34"/></svg>
|
||||
<strong class="cutscene-sfx">쾅!</strong>
|
||||
</div>
|
||||
<div class="cutscene-panel cutscene-fallen" data-panel="fallen">${fallenFighterArt(hitIndex, appearances?.[hitIndex])}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
export function ashPileArt() {
|
||||
return `<svg class="ash-pile-art" viewBox="0 0 260 300" fill="none" aria-hidden="true">
|
||||
<path d="m39 279 28-7 39-3 52 1 42 5 20 8-33 5-65 2-55-4Z" fill="#46504b" opacity=".18"/>
|
||||
<g stroke="#414a47" stroke-width="2" stroke-linejoin="round" stroke-linecap="round">
|
||||
<path d="m49 278 18-7 12-1 9-8 15 1 11-9 14 3 12-6 13 7 15-1 10 8 15 2 8 9 13 5-23 3-27-1-18 4-30-2-23 1-19-5-14 1Z" fill="#898e87"/>
|
||||
<path d="m64 277 25-4 18 3 22-6 18 4 20-3 17 7 18 1-11 5-27-1-18 4-30-2-23 1-19-5-14 1Z" fill="#606a65" stroke="none"/>
|
||||
<path d="m88 269 13-3 10 3m10-8 9 4 11-3m12 4 12 2m-58 10 13-3m20 4 13-2m18-1 8 3" stroke-width="1.3"/>
|
||||
<path d="m41 282 5-3 5 3-5 2Zm175-8 5 1-1 3-6-1Zm-141 16 6-2 5 2-6 1Zm103-1 8-2 4 2-7 1Z" fill="#707973" stroke-width="1"/>
|
||||
</g>
|
||||
<g class="ash-drift" fill="#66716b">
|
||||
<path d="m95 241 4-3 3 2-4 3Zm37-16 3-4 2 2-2 4Zm29 18 4-2 2 2-4 2Z"/>
|
||||
</g>
|
||||
<g class="ash-drift ash-drift-late" fill="#858e86">
|
||||
<path d="m111 234 3-2 2 2-3 2Zm38-20 2-3 2 2-2 2Zm27 22 3-2 2 1-3 3Z"/>
|
||||
</g>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
export function actionIcon(action: ActionId) {
|
||||
const shapes: Record<ActionId, string> = {
|
||||
charge: '<path d="M29 3 9 28h13l-3 17 21-26H27Z"/>',
|
||||
blast: '<path fill-rule="evenodd" d="M27 8h9l10 16-10 16h-9L15 30H3l9-6-9-6h12Zm2 10-5 6 5 6h4l4-6-4-6Z"/><path d="M3 8h14v6H3Zm0 26h14v6H3Z"/>',
|
||||
guard: '<path fill-rule="evenodd" d="m24 3 19 7v14l-6 12-13 9-13-9-6-12V10Zm0 9-10 4v8l5 8 5 4 5-4 5-8v-8Z"/>',
|
||||
beam: '<path fill-rule="evenodd" d="m4 18 9-6 10 5L45 4v40L23 31l-10 5-9-6Zm8 3v6h9l24 8V13l-24 8Z"/><path d="M17 22h28v4H17Z"/>',
|
||||
dodge: '<path d="m32 3 8 3-3 8-8-3ZM23 14l10 3 4 8h8v6H32l-4-6-3 8 7 4-2 9h-7l1-6-8-5 4-13h-6l-4 7H3l7-14Zm-10 23h7l-7 8H4ZM3 5h18v5H3Z"/>',
|
||||
};
|
||||
return `<svg viewBox="0 0 48 48" fill="currentColor" aria-hidden="true">${shapes[action]}</svg>`;
|
||||
}
|
||||
|
||||
export const checkIcon = '<svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="m5 12 5 5L20 7" stroke="currentColor" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/></svg>';
|
||||
@@ -0,0 +1,105 @@
|
||||
import { CUTSCENE_TIMING } from '../../constants/cutscene.js';
|
||||
import { ACTIONS } from '../../constants/gameplay.js';
|
||||
import type { LocalMatchState } from '../shared/p2p-types.js';
|
||||
import { appearanceKey, type FighterAppearance } from '../shared/cosmetics.js';
|
||||
import { finisherArt } from './fighter-art.js';
|
||||
|
||||
// Survives view replacement on reconnect, without persisting anything to game state.
|
||||
let presentation: { matchId: string; startedAt: number; dismissed: boolean } | undefined;
|
||||
|
||||
export function createFinishCutscene(onDismiss: () => void, appearances?: [FighterAppearance, FighterAppearance]) {
|
||||
let dialog: HTMLDialogElement | undefined, timer: ReturnType<typeof setTimeout> | undefined;
|
||||
let returnFocus: HTMLElement | undefined, previousBodyState: string | undefined;
|
||||
let activeMatch = '', resultAt = 0, artSignature = '';
|
||||
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
|
||||
|
||||
function close(restoreFocus: boolean) {
|
||||
clearTimeout(timer);
|
||||
if (!dialog) return;
|
||||
const node = dialog;
|
||||
dialog = undefined; activeMatch = '';
|
||||
if (previousBodyState === undefined) delete document.body.dataset.cutscene;
|
||||
else document.body.dataset.cutscene = previousBodyState;
|
||||
node.close(); node.remove();
|
||||
if (restoreFocus && returnFocus?.isConnected) returnFocus.focus({ preventScroll: true });
|
||||
returnFocus = undefined;
|
||||
}
|
||||
|
||||
function dismiss() {
|
||||
if (!dialog || dialog.dataset.stage !== 'complete') return;
|
||||
if (presentation?.matchId === activeMatch) presentation.dismissed = true;
|
||||
close(false);
|
||||
onDismiss();
|
||||
}
|
||||
|
||||
function advance() {
|
||||
if (!dialog || presentation?.matchId !== activeMatch) return;
|
||||
clearTimeout(timer);
|
||||
const now = performance.now(), elapsed = now - presentation.startedAt;
|
||||
const complete = (reducedMotion.matches || elapsed >= CUTSCENE_TIMING.complete) && now >= resultAt;
|
||||
const reactionEnds = dialog.dataset.attack === 'beam' ? CUTSCENE_TIMING.beamDissolve : CUTSCENE_TIMING.fallen;
|
||||
const stage = complete ? 'complete' : reducedMotion.matches || elapsed >= reactionEnds ? 'fallen'
|
||||
: elapsed >= CUTSCENE_TIMING.fallen ? 'reaction'
|
||||
: elapsed >= CUTSCENE_TIMING.impact ? 'impact' : 'attack';
|
||||
dialog.dataset.stage = stage;
|
||||
const button = dialog.querySelector<HTMLButtonElement>('.cutscene-result')!;
|
||||
button.hidden = !complete;
|
||||
if (complete) {
|
||||
if (document.activeElement === dialog) button.focus({ preventScroll: true });
|
||||
} else {
|
||||
const next = reducedMotion.matches ? resultAt : Math.max(resultAt, presentation.startedAt + CUTSCENE_TIMING.complete);
|
||||
const boundary = !reducedMotion.matches && elapsed < CUTSCENE_TIMING.impact ? presentation.startedAt + CUTSCENE_TIMING.impact
|
||||
: !reducedMotion.matches && elapsed < CUTSCENE_TIMING.fallen ? presentation.startedAt + CUTSCENE_TIMING.fallen
|
||||
: !reducedMotion.matches && elapsed < reactionEnds ? presentation.startedAt + reactionEnds : next;
|
||||
timer = setTimeout(advance, Math.max(0, boundary - now));
|
||||
}
|
||||
}
|
||||
|
||||
reducedMotion.addEventListener('change', advance);
|
||||
return {
|
||||
update(state: LocalMatchState) {
|
||||
const reveal = state.reveal;
|
||||
if (activeMatch && activeMatch !== state.matchId) close(true);
|
||||
if (state.aborted || state.finished?.reason !== 'hit' || !reveal || reveal.matchId !== state.matchId || reveal.hitIndex === null) {
|
||||
close(true); return;
|
||||
}
|
||||
if (presentation?.matchId === state.matchId && presentation.dismissed) return;
|
||||
const chosenAppearances = state.appearances ?? appearances;
|
||||
const signature = [0, 1].map(i => appearanceKey(chosenAppearances?.[i])).join('|');
|
||||
const hit = reveal.hitIndex, attacker = 1 - hit;
|
||||
if (dialog) {
|
||||
if (artSignature !== signature) {
|
||||
dialog.querySelector('.cutscene-page')!.outerHTML = finisherArt(hit, reveal.actions[attacker], chosenAppearances);
|
||||
artSignature = signature;
|
||||
}
|
||||
return;
|
||||
}
|
||||
const now = performance.now();
|
||||
if (presentation?.matchId !== state.matchId) {
|
||||
presentation = { matchId: state.matchId, startedAt: now - Math.max(0, state.clockNow - reveal.revealedAt), dismissed: false };
|
||||
}
|
||||
resultAt = now + Math.max(0, reveal.revealEndsAt - state.clockNow);
|
||||
activeMatch = state.matchId;
|
||||
returnFocus = document.activeElement instanceof HTMLElement ? document.activeElement : undefined;
|
||||
previousBodyState = document.body.dataset.cutscene;
|
||||
const name = (index: number) => index === state.selfIndex ? '나' : state.mode === 'bot' ? '봇' : '상대';
|
||||
dialog = document.createElement('dialog');
|
||||
dialog.className = 'finish-cutscene'; dialog.tabIndex = -1;
|
||||
dialog.dataset.hitIndex = String(hit);
|
||||
dialog.dataset.attack = reveal.actions[attacker];
|
||||
dialog.dataset.stage = 'attack';
|
||||
dialog.setAttribute('aria-label', `${name(attacker)}의 ${ACTIONS[reveal.actions[attacker]].name} 명중. ${hit === state.selfIndex ? '패배' : '승리'}.`);
|
||||
dialog.innerHTML = `${finisherArt(hit, reveal.actions[attacker], chosenAppearances)}<button class="cutscene-result" hidden>결과 보기</button>`;
|
||||
artSignature = signature;
|
||||
dialog.querySelector<HTMLButtonElement>('.cutscene-result')!.onclick = dismiss;
|
||||
dialog.addEventListener('cancel', event => { event.preventDefault(); dismiss(); });
|
||||
document.body.append(dialog);
|
||||
document.body.dataset.cutscene = 'active';
|
||||
dialog.showModal();
|
||||
dialog.focus({ preventScroll: true });
|
||||
advance();
|
||||
},
|
||||
reset() { close(true); },
|
||||
destroy() { close(true); reducedMotion.removeEventListener('change', advance); },
|
||||
};
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,93 @@
|
||||
Copyright 2015 The Black Han Sans Project Authors (https://github.com/zesstype/Black-Han-Sans)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://openfontlicense.org
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -0,0 +1,202 @@
|
||||
import { MATCH_COUNTDOWN_MS, RULES_VERSION } from '../../constants/gameplay.js';
|
||||
import { RECONNECT_GRACE_MS } from '../../constants/session.js';
|
||||
import { PEER_PING_MS, PEER_SILENCE_MS, PROTOCOL_TIMEOUT_MS, PROTOCOL_VERSION, RESUME_START_DELAY_MS } from '../../constants/webrtc.js';
|
||||
import type { MatchEngine } from '../shared/match-engine.js';
|
||||
import { createTurnExchange, digestValue, type TurnExchange } from '../shared/peer-protocol.js';
|
||||
import type { MatchCheckpoint, PeerMessage, PublicCheckpoint } from '../shared/p2p-types.js';
|
||||
import type { ActionId, Pair } from '../shared/types.js';
|
||||
import type { GameClock } from './match-controller.js';
|
||||
import type { PeerConnection } from './peer.js';
|
||||
|
||||
export type GamePeer = Pick<PeerConnection, 'connect' | 'connected' | 'epoch' | 'send' | 'onMessage' | 'onState' | 'restart' | 'close'>;
|
||||
interface FriendConfig {
|
||||
matchId: string; slot: 0 | 1; engine: MatchEngine; peer: GamePeer; clock: GameClock;
|
||||
setOffset(offset: number): void; changed(): void; finished(): void;
|
||||
saved?: { confirmed?: MatchCheckpoint; epoch: number };
|
||||
}
|
||||
export function createFriendMatch(config: FriendConfig) {
|
||||
const { peer, engine, clock } = config;
|
||||
let stopped = false, recovering = false, offset = 0, localReady = false, peerReady = false, begun = false;
|
||||
let confirmed = config.saved?.confirmed ?? engine.checkpoint();
|
||||
let exchange: TurnExchange | undefined, ownDigest = '', remoteDigest = '', remoteAck = '', sentAck = false, presented = false;
|
||||
let presentAt: number | undefined, privateChoice: { action: ActionId | null; nonce: string } | undefined;
|
||||
let resumedPeer = false, resumedAck = false, lastPeerAt = clock.now(), queue = Promise.resolve();
|
||||
let leaveResolve: (() => void) | undefined;
|
||||
let leaving = false;
|
||||
const timers = new Map<string, any>();
|
||||
const clockReplies = new Map<number, { resolve(value: number): void; reject(error: Error): void }>();
|
||||
const now = () => clock.now() + offset;
|
||||
function clear(name: string) { clock.clearTimeout(timers.get(name)); timers.delete(name); }
|
||||
function later(name: string, fn: () => void, ms: number) { clear(name); timers.set(name, clock.setTimeout(() => { timers.delete(name); if (!stopped) fn(); }, Math.max(0, ms))); }
|
||||
function clearAll() { for (const name of [...timers.keys()]) clear(name); }
|
||||
function run(fn: () => unknown | Promise<unknown>) { void Promise.resolve().then(fn).catch(() => { if (!stopped) abort('protocol_error'); }); }
|
||||
function send(type: PeerMessage['type'], fields: Record<string, unknown> = {}) {
|
||||
peer.send({ protocolVersion: PROTOCOL_VERSION, rulesVersion: RULES_VERSION, matchId: config.matchId,
|
||||
connectionEpoch: peer.epoch, turnId: engine.view().turnId, type, ...fields } as PeerMessage);
|
||||
}
|
||||
function abort(reason: 'connection_failed' | 'protocol_error') {
|
||||
if (stopped) return;
|
||||
clearAll(); recovering = false;
|
||||
// An unconfirmed calculation is not a final result and may be discarded.
|
||||
if (!presented && confirmed.state.phase !== 'finished') engine.restore(confirmed);
|
||||
engine.abort(reason); config.changed(); config.finished();
|
||||
stopped = true; peer.close(); leaveResolve?.();
|
||||
}
|
||||
function armProtocol() { later('protocol', () => abort('protocol_error'), PROTOCOL_TIMEOUT_MS); }
|
||||
function publicCheckpoint(): PublicCheckpoint {
|
||||
const state = confirmed.state;
|
||||
return { turnId: state.phase === 'revealing' ? state.turnId + 1 : Math.max(1, state.turnId), energy: [...state.energy],
|
||||
finished: state.phase === 'finished', winnerIndex: state.finished?.winnerIndex ?? null, ...(state.finished ? { reason: state.finished.reason } : {}) };
|
||||
}
|
||||
function sameCheckpoint(a: PublicCheckpoint, b: PublicCheckpoint) {
|
||||
return a.turnId === b.turnId && a.energy?.[0] === b.energy[0] && a.energy?.[1] === b.energy[1] && a.finished === b.finished && a.winnerIndex === b.winnerIndex && a.reason === b.reason;
|
||||
}
|
||||
function heartbeat() {
|
||||
if (stopped || recovering) return;
|
||||
if (clock.now() - lastPeerAt >= PEER_SILENCE_MS) { void recover(); return; }
|
||||
try { send('ping', { sentAt: clock.now() }); } catch { void recover(); return; }
|
||||
later('heartbeat', heartbeat, PEER_PING_MS);
|
||||
}
|
||||
async function synchronize() {
|
||||
if (config.slot === 0) return;
|
||||
let bestRtt = Infinity, bestOffset = offset;
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const sentAt = clock.now();
|
||||
const response = new Promise<number>((resolve, reject) => clockReplies.set(sentAt, { resolve, reject }));
|
||||
later('clock', () => { clockReplies.get(sentAt)?.reject(new Error('CLOCK_TIMEOUT')); clockReplies.delete(sentAt); }, PROTOCOL_TIMEOUT_MS);
|
||||
send('clock:ping', { sentAt }); const remoteAt = await response;
|
||||
clear('clock'); const rtt = clock.now() - sentAt;
|
||||
if (rtt < bestRtt) { bestRtt = rtt; bestOffset = remoteAt - (sentAt + clock.now()) / 2; }
|
||||
}
|
||||
offset = bestOffset; config.setOffset(offset);
|
||||
}
|
||||
function present(at: number) {
|
||||
if (presented) return;
|
||||
presented = true; presentAt = undefined; clear('protocol'); engine.present(at); confirmed = engine.checkpoint();
|
||||
config.changed();
|
||||
if (engine.view().phase === 'finished') config.finished();
|
||||
else if (config.slot === 0) later('next', () => startTurn(engine.view().reveal!.revealEndsAt), engine.view().reveal!.revealEndsAt - now());
|
||||
}
|
||||
function agree() {
|
||||
if (!ownDigest || !remoteDigest) return;
|
||||
if (ownDigest !== remoteDigest) { abort('protocol_error'); return; }
|
||||
if (!sentAck) { sentAck = true; send('turn:ack', { digest: ownDigest }); }
|
||||
if (remoteAck && remoteAck !== ownDigest) { abort('protocol_error'); return; }
|
||||
if (remoteAck !== ownDigest || presented) return;
|
||||
// Agreement survives loss of the presentation packet and a page reload.
|
||||
confirmed = engine.checkpoint(); config.changed();
|
||||
if (confirmed.state.phase === 'finished') config.finished();
|
||||
if (config.slot === 0) { const at = now(); send('turn:present', { revealedAt: at }); present(at); }
|
||||
else if (presentAt !== undefined) present(presentAt);
|
||||
}
|
||||
async function resolved(actions: Pair<ActionId | null>) {
|
||||
engine.resolve(actions);
|
||||
const state = engine.view();
|
||||
ownDigest = await digestValue([config.matchId, peer.epoch, state.turnId, state.energy, actions, state.finished?.winnerIndex ?? null, state.finished?.reason ?? null]);
|
||||
if (stopped || recovering) return;
|
||||
send('turn:resolved', { digest: ownDigest }); agree();
|
||||
}
|
||||
function selection() {
|
||||
privateChoice = undefined; ownDigest = remoteDigest = remoteAck = ''; sentAck = false; presented = false; presentAt = undefined;
|
||||
const state = engine.view();
|
||||
exchange = createTurnExchange({ matchId: config.matchId, epoch: peer.epoch, turnId: state.turnId, slot: config.slot, energy: state.energy,
|
||||
send: message => peer.send(message), onResolved: actions => run(() => resolved(actions)),
|
||||
onPrivateState: value => { privateChoice = value; config.changed(); }, onPhase: () => armProtocol() });
|
||||
config.changed();
|
||||
later('deadline', () => run(() => exchange!.commit(engine.view().ownAction)), state.deadline - now());
|
||||
}
|
||||
function applyStart(startsAt: number, resume = false) {
|
||||
if (!Number.isFinite(startsAt)) { abort('protocol_error'); return; }
|
||||
if (resume) {
|
||||
engine.resumeTurn(confirmed, startsAt); recovering = false; clear('recovery'); engine.presence(true); selection(); heartbeat(); return;
|
||||
}
|
||||
if (!begun) { begun = true; engine.start(startsAt - MATCH_COUNTDOWN_MS); confirmed = engine.checkpoint(); config.changed(); }
|
||||
later('start', () => { if (engine.beginTurn(startsAt)) selection(); }, startsAt - now());
|
||||
}
|
||||
function startTurn(startsAt: number, resume = false) {
|
||||
const turnId = resume ? publicCheckpoint().turnId : engine.view().turnId + 1;
|
||||
send('turn:start', { startsAt, resume, turnId }); applyStart(startsAt, resume);
|
||||
}
|
||||
function maybeStart() {
|
||||
if (localReady && peerReady && config.slot === 0 && !begun && !recovering) startTurn(now() + MATCH_COUNTDOWN_MS);
|
||||
}
|
||||
function maybeResume() {
|
||||
if (!recovering || !resumedPeer || !resumedAck) return;
|
||||
if (confirmed.state.phase === 'finished') { engine.restore(confirmed); recovering = false; clear('recovery'); config.changed(); config.finished(); return; }
|
||||
if (config.slot === 0) startTurn(now() + RESUME_START_DELAY_MS, true);
|
||||
}
|
||||
async function recover(disconnectedAt = clock.now()) {
|
||||
if (stopped || recovering) return;
|
||||
if (confirmed.state.phase === 'finished') {
|
||||
clearAll(); if (engine.view().phase !== 'finished') engine.restore(confirmed);
|
||||
engine.present(now()); engine.presence(false); config.changed(); config.finished(); return;
|
||||
}
|
||||
recovering = true; resumedPeer = resumedAck = false; clearAll(); exchange = undefined;
|
||||
engine.presence(false); config.changed();
|
||||
later('recovery', () => abort('connection_failed'), RECONNECT_GRACE_MS - (clock.now() - disconnectedAt));
|
||||
try {
|
||||
await peer.restart(peer.epoch + 1); if (stopped) return;
|
||||
lastPeerAt = clock.now(); await synchronize(); if (stopped) return;
|
||||
send('resume', { checkpoint: publicCheckpoint() });
|
||||
} catch { abort('connection_failed'); }
|
||||
}
|
||||
async function receive(message: PeerMessage) {
|
||||
if (stopped || !message || message.matchId !== config.matchId || message.protocolVersion !== PROTOCOL_VERSION || message.rulesVersion !== RULES_VERSION || message.connectionEpoch !== peer.epoch) return;
|
||||
lastPeerAt = clock.now();
|
||||
if (message.type === 'ping' || message.type === 'clock:ping') { send(message.type === 'ping' ? 'pong' : 'clock:pong', { sentAt: message.sentAt, remoteAt: now() }); return; }
|
||||
if (message.type === 'clock:pong') { if (Number.isFinite(message.remoteAt)) clockReplies.get(message.sentAt)?.resolve(message.remoteAt); clockReplies.delete(message.sentAt); return; }
|
||||
if (message.type === 'pong') return;
|
||||
if (message.type === 'ready') { if (message.slot === config.slot) throw new Error('INVALID_SLOT'); peerReady = true; maybeStart(); return; }
|
||||
if (message.type === 'resume' || message.type === 'resume:ack') {
|
||||
if (!recovering) return;
|
||||
if (!sameCheckpoint(message.checkpoint, publicCheckpoint())) { abort('protocol_error'); return; }
|
||||
if (message.type === 'resume') { resumedPeer = true; send('resume:ack', { checkpoint: publicCheckpoint() }); }
|
||||
else resumedAck = true;
|
||||
maybeResume(); return;
|
||||
}
|
||||
if (message.type === 'turn:start') {
|
||||
if (config.slot !== 1) throw new Error('INVALID_ROLE');
|
||||
if (message.resume) { if (recovering && resumedPeer && resumedAck) applyStart(message.startsAt, true); return; }
|
||||
if (recovering || message.turnId !== engine.view().turnId + 1) return;
|
||||
applyStart(message.startsAt); return;
|
||||
}
|
||||
if (message.type === 'match:leave') {
|
||||
send('match:leave-ack'); clearAll(); engine.finish(leaving ? null : config.slot, 'forfeit'); confirmed = engine.checkpoint(); presented = true; config.changed(); config.finished(); return;
|
||||
}
|
||||
if (message.type === 'match:leave-ack') { leaveResolve?.(); return; }
|
||||
if (message.type === 'match:rematch') { engine.requestRematch(config.slot === 0 ? 1 : 0); config.changed(); return; }
|
||||
if (recovering || message.turnId !== engine.view().turnId) return;
|
||||
if (message.type === 'turn:commit' || message.type === 'turn:reveal') { await exchange?.receive(message); return; }
|
||||
if (message.type === 'turn:resolved') { if (remoteDigest && remoteDigest !== message.digest) throw new Error('RESULT_CHANGED'); remoteDigest = message.digest; agree(); }
|
||||
else if (message.type === 'turn:ack') { remoteAck = message.digest; agree(); }
|
||||
else if (message.type === 'turn:present') { if (config.slot !== 1 || !Number.isFinite(message.revealedAt)) throw new Error('INVALID_PRESENT'); presentAt = message.revealedAt; agree(); }
|
||||
}
|
||||
const offMessage = peer.onMessage(message => { queue = queue.then(() => receive(message)).catch(() => abort('protocol_error')); });
|
||||
const offState = peer.onState(state => {
|
||||
if (state === 'disconnected' || state === 'failed') { const disconnectedAt = clock.now(); void queue.then(() => recover(disconnectedAt)); }
|
||||
});
|
||||
return {
|
||||
async start() {
|
||||
if (config.saved?.confirmed) {
|
||||
begun = true; engine.restore(confirmed); await recover(); return;
|
||||
}
|
||||
try { await peer.connect(); await synchronize(); if (stopped) return; localReady = true; send('ready', { slot: config.slot }); maybeStart(); lastPeerAt = clock.now(); heartbeat(); }
|
||||
catch { abort('connection_failed'); }
|
||||
},
|
||||
get recovering() { return recovering; },
|
||||
requestRematch() { engine.requestRematch(config.slot); if (peer.connected) send('match:rematch'); config.changed(); },
|
||||
snapshot: () => ({ confirmed: structuredClone(confirmed), epoch: peer.epoch, privateChoice }),
|
||||
async leave() {
|
||||
if (engine.view().phase === 'finished') return;
|
||||
leaving = true;
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
leaveResolve = resolve; later('leave', () => reject(new Error('LEAVE_TIMEOUT')), PROTOCOL_TIMEOUT_MS); send('match:leave');
|
||||
});
|
||||
clearAll(); engine.finish(config.slot === 0 ? 1 : 0, 'forfeit'); confirmed = engine.checkpoint(); presented = true; config.changed(); config.finished();
|
||||
} catch { abort('connection_failed'); }
|
||||
finally { leaveResolve = undefined; }
|
||||
},
|
||||
destroy() { stopped = true; clearAll(); offMessage(); offState(); for (const pending of clockReplies.values()) pending.reject(new Error('CLOSED')); clockReplies.clear(); peer.close(); },
|
||||
};
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,177 @@
|
||||
/* Home and room entry only; all selectors are scoped to the player app. */
|
||||
body.player-app:is([data-screen=home], [data-screen=join]) .app {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
padding: 0;
|
||||
}
|
||||
body.player-app:is([data-screen=home], [data-screen=join]) main { min-height: 100svh; padding: 0; }
|
||||
body.player-app .home {
|
||||
flex: 1;
|
||||
grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr);
|
||||
gap: 0;
|
||||
max-width: none;
|
||||
margin: 0;
|
||||
align-items: stretch;
|
||||
}
|
||||
body.player-app .home-stage {
|
||||
min-height: 100svh;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: linear-gradient(125deg, #ffbd43, #ff8b26 75%);
|
||||
clip-path: polygon(0 0, 100% 0, 83% 100%, 0 100%);
|
||||
}
|
||||
body.player-app .home-stage::before {
|
||||
inset: 16% auto auto -28%;
|
||||
width: 660px;
|
||||
height: 660px;
|
||||
border: 2px solid #fff4ce80;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, #fff3c14d 1px, transparent 1.5px) 0 0 / 12px 12px;
|
||||
}
|
||||
body.player-app .home-stage::after {
|
||||
inset: 0;
|
||||
width: auto;
|
||||
height: auto;
|
||||
border: 0;
|
||||
transform: none;
|
||||
background: repeating-linear-gradient(118deg, transparent 0 120px, #fff4ce35 121px 123px, transparent 124px 220px);
|
||||
}
|
||||
body.player-app .home h1 {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
top: max(40px, env(safe-area-inset-top));
|
||||
left: max(36px, env(safe-area-inset-left));
|
||||
margin: 0;
|
||||
color: #202122;
|
||||
font-size: clamp(54px, 6.2vw, 104px);
|
||||
line-height: 1.12;
|
||||
letter-spacing: -.055em;
|
||||
text-align: left;
|
||||
transform: rotate(-5deg);
|
||||
text-shadow: 3px 4px 0 #ffe6a3;
|
||||
}
|
||||
body.player-app .home h1 span { display: block; }
|
||||
body.player-app .home h1 span:last-child { font-size: .82em; }
|
||||
body.player-app #home-fighters {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
body.player-app .home-fighter { position: absolute; max-width: none; filter: drop-shadow(8px 8px 0 #70391b25); }
|
||||
body.player-app .home-fighter:first-child { width: 89%; left: -15%; top: max(230px, 30%); z-index: 2; transform: rotate(-9deg); }
|
||||
body.player-app .home-fighter:last-child { width: 73%; right: -8%; top: max(250px, 33%); z-index: 1; transform: scaleX(-1) rotate(-8deg); }
|
||||
body.player-app .home-fighter .character { animation: none; }
|
||||
body.player-app .home-fighter .ground-shadow, body.player-app .home-versus { display: none; }
|
||||
body.player-app .home-fighter-teleport { width: 54%; left: 34%; top: max(80px, 10%); z-index: 0; transform: rotate(4deg); }
|
||||
body.player-app .home-entering .home-fighter-left { animation: home-slide-left .65s cubic-bezier(.16, 1, .3, 1) both; }
|
||||
body.player-app .home-entering .home-fighter-right { animation: home-slide-right .65s .25s cubic-bezier(.16, 1, .3, 1) both; }
|
||||
body.player-app .home-entering .home-fighter-teleport { animation: home-teleport-in .45s .9s steps(1, end) both; }
|
||||
body.player-app .home-entering .home-fighter-teleport::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 5% 22% 12%;
|
||||
pointer-events: none;
|
||||
background: repeating-linear-gradient(90deg, transparent 0 13%, #fff4ce 14% 16%, transparent 17% 25%);
|
||||
animation: home-teleport-streaks .45s .9s ease-out both;
|
||||
}
|
||||
@keyframes home-slide-left {
|
||||
from { translate: -120% 0; opacity: 0; }
|
||||
to { translate: 0 0; opacity: 1; }
|
||||
}
|
||||
@keyframes home-slide-right {
|
||||
from { translate: 120% 0; opacity: 0; }
|
||||
to { translate: 0 0; opacity: 1; }
|
||||
}
|
||||
@keyframes home-teleport-in {
|
||||
0% { opacity: 0; }
|
||||
20% { opacity: .65; translate: 0 -12px; }
|
||||
35% { opacity: 0; }
|
||||
50% { opacity: 1; translate: 0 0; }
|
||||
65% { opacity: .3; }
|
||||
80%, 100% { opacity: 1; translate: 0 0; }
|
||||
}
|
||||
@keyframes home-teleport-streaks {
|
||||
0%, 100% { opacity: 0; scale: 1 .8; }
|
||||
20% { opacity: .9; scale: 1 1.2; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
body.player-app .home-entering .home-fighter { animation: none; }
|
||||
body.player-app .home-entering .home-fighter-teleport::after { display: none; }
|
||||
}
|
||||
body.player-app .home-menu {
|
||||
position: relative;
|
||||
z-index: 4;
|
||||
align-self: center;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
margin-left: -24px;
|
||||
padding: 64px max(32px, 4vw) 120px 0;
|
||||
}
|
||||
body.player-app .home-actions, body.player-app .home-actions .split { display: grid; gap: 14px; }
|
||||
body.player-app .home-actions button {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
min-height: 80px;
|
||||
padding: 20px 28px;
|
||||
font-size: clamp(22px, 2.3vw, 32px);
|
||||
letter-spacing: -.035em;
|
||||
text-align: left;
|
||||
}
|
||||
body.player-app .home-actions > .primary { min-height: 108px; padding: 24px 28px; font-size: clamp(30px, 3.1vw, 44px); }
|
||||
body.player-app .home-actions svg { flex: 0 0 28px; width: 28px; height: 28px; }
|
||||
body.player-app .home-actions > .primary svg { flex-basis: 36px; width: 36px; height: 36px; }
|
||||
body.player-app .home .stats { margin-top: 32px; padding-top: 20px; border-top: 2px solid #242526; }
|
||||
body.player-app .home .stat-mode { padding: 0 20px; }
|
||||
body.player-app .home .stat-mode:first-child { padding-left: 0; }
|
||||
body.player-app .home .stat-mode + .stat-mode { border-left: 1px solid #d6d6d6; }
|
||||
body.player-app .home .stat-mode > span { font-size: 13px; color: #242526; }
|
||||
body.player-app .home .stat-mode > div { gap: 24px; color: #606060; }
|
||||
body.player-app .home .stat-mode strong { color: #242526; font-size: 30px; }
|
||||
body.player-app .home .join-form { margin-top: 24px; padding: 20px; background: #fff; border: 2px solid #242526; box-shadow: 5px 5px 0 #2425261c; }
|
||||
body.player-app .home .join-form > .quiet { margin: 14px auto 0; }
|
||||
body.player-app:is([data-screen=home], [data-screen=join]) #notice:not(:empty) { position: absolute; bottom: 90px; right: 4vw; width: 36%; margin: 0; }
|
||||
body.player-app:is([data-screen=home], [data-screen=join]) #reconnect { position: absolute; bottom: 150px; right: 4vw; }
|
||||
@media (max-width: 1050px) and (min-width: 761px) {
|
||||
body.player-app .home-fighter:first-child { width: 106%; left: -23%; }
|
||||
body.player-app .home-fighter:last-child { width: 88%; right: -16%; }
|
||||
body.player-app .home h1 { left: 24px; }
|
||||
body.player-app .home-menu { margin-left: -12px; padding-right: 24px; }
|
||||
body.player-app .home-actions button { padding-inline: 20px; }
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
body.player-app:is([data-screen=home], [data-screen=join]) main { min-height: 0; }
|
||||
body.player-app .home { grid-template-columns: minmax(0, 1fr); }
|
||||
body.player-app .home-stage { height: clamp(300px, 48vw, 390px); min-height: 0; clip-path: polygon(0 0, 100% 0, 100% 88%, 0 100%); }
|
||||
body.player-app .home-stage::before { width: 420px; height: 420px; }
|
||||
body.player-app .home h1 { top: max(22px, env(safe-area-inset-top)); left: max(22px, env(safe-area-inset-left)); font-size: clamp(44px, 8vw, 60px); }
|
||||
body.player-app .home-fighter:first-child { width: 65%; left: 8%; top: 110px; }
|
||||
body.player-app .home-fighter:last-child { width: 54%; right: -4%; top: 115px; }
|
||||
body.player-app .home-fighter-teleport { width: 40%; left: 43%; top: -5px; }
|
||||
body.player-app .home-menu { width: min(100%, 520px); margin: -4px auto 0; padding: 0 max(20px, env(safe-area-inset-right)) 20px max(20px, env(safe-area-inset-left)); }
|
||||
body.player-app .home-actions, body.player-app .home-actions .split { gap: 12px; }
|
||||
body.player-app .home-actions button { min-height: 60px; padding: 13px 20px; font-size: 23px; }
|
||||
body.player-app .home-actions > .primary { min-height: 78px; padding: 16px 20px; font-size: 31px; }
|
||||
body.player-app .home-actions svg { width: 23px; height: 23px; flex-basis: 23px; }
|
||||
body.player-app .home-actions > .primary svg { width: 29px; height: 29px; flex-basis: 29px; }
|
||||
body.player-app .home .stats { margin-top: 24px; padding-top: 16px; }
|
||||
body.player-app .home .stat-mode { padding-inline: 16px; }
|
||||
body.player-app .home .stat-mode strong { font-size: 26px; }
|
||||
body.player-app .home .stat-mode > div { gap: 20px; }
|
||||
body.player-app .home .join-form { padding: 14px; }
|
||||
body.player-app:is([data-screen=home], [data-screen=join]) #notice:not(:empty),
|
||||
body.player-app:is([data-screen=home], [data-screen=join]) #reconnect { position: static; width: auto; margin: 12px 20px; }
|
||||
}
|
||||
@media (max-width: 380px) {
|
||||
body.player-app .home-stage { height: 270px; }
|
||||
body.player-app .home-actions button { min-height: 54px; padding-block: 10px; font-size: 21px; }
|
||||
body.player-app .home-actions > .primary { min-height: 68px; font-size: 28px; }
|
||||
body.player-app .home .stat-mode > div { gap: 16px; }
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"><meta name="theme-color" content="#ffffff"><title>쎄쎄쎄:기공대전</title></head>
|
||||
<body class="player-app" data-screen="home">
|
||||
<div class="app">
|
||||
<main id="main">
|
||||
<section id="home" class="home">
|
||||
<div class="home-stage"><h1 aria-label="쎄쎄쎄:기공대전"><span>쎄쎄쎄</span><span>:기공대전</span></h1><div id="home-fighters" aria-hidden="true"></div></div>
|
||||
<div class="home-menu">
|
||||
<div class="home-actions"><button id="bot-start" class="primary" disabled><span>봇과 대전</span><svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M4 12h15m-6-6 6 6-6 6" stroke="currentColor" stroke-width="3" stroke-linecap="square" stroke-linejoin="miter"/></svg></button><div class="split"><button id="room-create" disabled><span>방 만들기</span><svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M12 4v16M4 12h16" stroke="currentColor" stroke-width="2"/></svg></button><button id="join-open" disabled><span>방 참가</span><svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M13 4h7v16h-7M3 12h12m-5-5 5 5-5 5" stroke="currentColor" stroke-width="2"/></svg></button></div></div>
|
||||
<form id="join-form" class="join-form" hidden><label for="room-code">방 코드</label><div><input id="room-code" autocomplete="off" autocapitalize="characters" maxlength="6" pattern="[A-Za-z0-9]{6}" required><button type="submit" class="primary">참가</button></div><button type="button" id="join-close" class="quiet">취소</button></form>
|
||||
<div id="stats" class="stats"></div>
|
||||
</div>
|
||||
</section>
|
||||
<section id="waiting" class="waiting" hidden><div id="waiting-fighter" aria-hidden="true"></div><h1>상대 대기 중<span class="waiting-dots" aria-hidden="true"><i></i><i></i><i></i></span></h1><p class="room-code" id="waiting-code"></p><div class="split"><button id="copy-code">코드 복사</button><button id="copy-link">초대 링크 복사</button></div><p id="room-expiry" class="subtle"></p><button id="waiting-leave" class="quiet">나가기</button></section>
|
||||
<section id="match" hidden></section>
|
||||
<p id="notice" class="notice" role="status" aria-live="polite"></p>
|
||||
<button id="reconnect" class="primary" hidden>다시 시도</button>
|
||||
</main>
|
||||
<footer class="app-footer"><span id="connection" role="status">연결 중</span><nav aria-label="안내"><button type="button" class="quiet" id="rules-open" aria-haspopup="dialog" aria-controls="rules-dialog">rule</button><span aria-hidden="true">|</span><button type="button" class="quiet" id="privacy-open" aria-haspopup="dialog" aria-controls="privacy-dialog">privacy</button><span aria-hidden="true">|</span><button type="button" class="quiet" id="contact-open" aria-haspopup="dialog" aria-controls="contact-dialog">contact</button></nav></footer>
|
||||
</div>
|
||||
<dialog id="rules-dialog"><div class="dialog-top"><h2>규칙</h2><button class="quiet" data-close aria-label="규칙 닫기">닫기</button></div><p>기 0으로 시작합니다. 공격에 한 번 맞으면 종료됩니다.</p><div id="rules-list"></div><p>선택 시간 4초. 선택은 바꿀 수 없고 마감 때 함께 공개됩니다.</p><p>한쪽 미입력은 패배, 양쪽 미입력은 무승부입니다. 상대에게 나가기가 전달되면 기권 패배입니다.</p><p>친구 대전 연결이 끊기면 최대 10초간 복구합니다. 확정되지 않은 선택은 취소됩니다. 복구하지 못하면 전적 변경 없이 중단합니다.</p><p>같은 공격끼리는 상쇄됩니다. 공격과 순간이동은 실패해도 기를 소모합니다.</p><p>봇·친구 연승은 탭의 접속 세션별로 저장합니다. 같은 탭 새로고침은 복원되며, 저장 정보 없는 새 탭은 새 전적입니다.</p><p>랭킹은 최대 연승, 누적 승 순입니다. 무승부는 연승을 유지합니다. 랭킹 기록·캐릭터·도복은 같은 브라우저의 익명 방문자 정보에 저장됩니다.</p></dialog>
|
||||
<dialog id="privacy-dialog"><div class="dialog-top"><h2>개인정보·분석 안내</h2><button class="quiet" data-close aria-label="개인정보·분석 안내 닫기">닫기</button></div><p>브라우저에 익명 방문자 토큰을, 탭에 전적 복원 정보를 저장합니다. 새 전적은 마지막 대전 등록·결과 저장부터 90일간 보관합니다.</p><p>익명 방문·화면 전환·메뉴 조작·대전 시작과 결과·기기 분류·유입 도메인을 수집합니다. 방문 분석은 90일간 보관하며, 과거 서버 판정 대전 기록은 계속 보관합니다.</p><p>자동으로 부여한 랭킹 이름·대전 결과·선택한 캐릭터와 도복은 계속 보관합니다. 랭킹 이름과 최대 연승·누적 승은 리더보드에 공개됩니다.</p><p>저장소를 삭제하면 이전 기록에 다시 연결할 수 없습니다.</p></dialog>
|
||||
<dialog id="contact-dialog" aria-labelledby="contact-title"><div class="dialog-top"><h2 id="contact-title">문의</h2><button type="button" class="quiet" data-close aria-label="문의 닫기">닫기</button></div><div id="contact-content"><a href="mailto:sunha321@gmail.com">sunha321@gmail.com</a></div></dialog>
|
||||
<script type="module" src="./main.ts"></script>
|
||||
</body></html>
|
||||
@@ -0,0 +1,65 @@
|
||||
.lobby-tools { display: flex; gap: 10px; margin-top: 16px; }
|
||||
.lobby-tools button { flex: 1; min-width: 0; min-height: 48px; padding: 10px; font-size: 17px; }
|
||||
#home .home-actions > button:not(.primary) { min-height: 64px; font-size: 26px; }
|
||||
#home .home-actions .split { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
#home .home-actions .split button { min-height: 62px; font-size: 22px; padding-inline: 16px; }
|
||||
#home .home-actions .split svg { flex-basis: 22px; width: 22px; height: 22px; }
|
||||
#home .home-actions .split button span { white-space: nowrap; }
|
||||
.home .home-actions { gap: 12px; }
|
||||
.queue-fighter { width: min(300px, 75vw); margin: 0 auto; }
|
||||
#appearance-dialog { max-width: 920px; max-height: calc(100svh - 32px); }
|
||||
#appearance-dialog h3 { margin: 0; font-size: 16px; }
|
||||
.appearance-layout { display: grid; grid-template-columns: minmax(0, .75fr) minmax(0, 1.4fr); gap: 24px; margin-top: 24px; align-items: center; }
|
||||
.appearance-preview { overflow: hidden; background: #ffb139; border: 2px solid #242526; }
|
||||
.appearance-preview .fighter-art { aspect-ratio: 260 / 300; }
|
||||
.skin-options { display: flex; gap: 10px; margin: 12px 0 24px; }
|
||||
.skin-options button { flex: 1; min-width: 0; display: flex; align-items: center; justify-content: center; gap: 8px; padding: 10px 8px; font-size: 16px; }
|
||||
.skin-swatch { flex-shrink: 0; width: 22px; height: 22px; border: 1px solid currentColor; background: var(--swatch); }
|
||||
.color-options { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; margin-top: 14px; }
|
||||
body.player-app .color-options button { min-width: 0; padding: 8px 6px 10px; display: flex; flex-direction: column; align-items: center; gap: 7px; background: #fff; color: #242526; box-shadow: 3px 3px 0 #24252630; }
|
||||
.color-swatch { display: block; width: 100%; height: 46px; border: 1px solid #24252660; background: var(--swatch); }
|
||||
.color-name { font-size: 14px; }
|
||||
body.player-app .color-requirement { font-family: var(--ui-body); font-size: 13px; font-weight: 700; line-height: 1.5; word-break: keep-all; }
|
||||
.color-lock { position: absolute; top: 4px; right: 4px; width: 25px; height: 25px; padding: 3px; background: #242526; color: #fff; }
|
||||
.color-lock svg { display: block; width: 100%; height: 100%; }
|
||||
body.player-app :is(.skin-options, .color-options, .hair-options, .equipment-options, .eyewear-options) button[aria-pressed=true] { background: #ffdfb5; color: #242526; box-shadow: inset 0 0 0 2px #ff912b, 3px 3px 0 #242526; }
|
||||
.hair-options { display: flex; gap: 10px; margin: 12px 0 24px; }
|
||||
.hair-options button { flex: 1; min-width: 0; padding: 10px 6px; font-size: 15px; word-break: keep-all; }
|
||||
.equipment-options, .eyewear-options { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; margin-top: 14px; }
|
||||
body.player-app :is(.equipment-options, .eyewear-options) button { display: flex; flex-direction: column; gap: 8px; align-items: center; justify-content: center; padding: 26px 6px 12px; background: #fff; color: #242526; font-size: 15px; }
|
||||
#appearance-dialog .eyewear-heading { margin-top: 24px; }
|
||||
.equipment-state { font-family: var(--ui-body); font-size: 12px; }
|
||||
.appearance-options .equip { margin-top: 16px; width: 100%; }
|
||||
.color-condition, .profile-notice { min-height: 24px; }
|
||||
body.player-app #appearance-dialog .color-condition { color: #242526; font-weight: 700; }
|
||||
.profile-notice:empty { display: none; }
|
||||
#leaderboard-dialog { max-width: 680px; }
|
||||
.leaderboard-content { overflow-x: auto; }
|
||||
.leaderboard-content table { width: 100%; border-collapse: collapse; font-size: 14px; }
|
||||
.leaderboard-content th, .leaderboard-content td { padding: 12px 6px; text-align: right; border-bottom: 1px solid #d2cec4; font-variant-numeric: tabular-nums; }
|
||||
.leaderboard-content th:nth-child(2), .leaderboard-content td:nth-child(2) { text-align: left; overflow-wrap: anywhere; }
|
||||
.leaderboard-content .self-ranking { background: #ffdfae; }
|
||||
body[data-screen=ranked-queue] .app { min-height: 100svh; }
|
||||
@media(max-width:760px) {
|
||||
#home .home-actions > button:not(.primary) { min-height: 54px; font-size: 23px; }
|
||||
#home .home-actions .split button { min-height: 54px; font-size: 20px; padding-inline: 12px; }
|
||||
.lobby-tools { margin-top: 12px; }
|
||||
.lobby-tools button { font-size: 16px; }
|
||||
}
|
||||
@media(max-width:520px) {
|
||||
#home .home-actions .split button { font-size: 18px; gap: 6px; padding-inline: 10px; }
|
||||
#home .home-actions .split svg { flex-basis: 18px; width: 18px; height: 18px; }
|
||||
.skin-options button { font-size: 14px; }
|
||||
.hair-options { gap: 6px; }
|
||||
.hair-options button { font-size: 13px; }
|
||||
.appearance-layout { grid-template-columns: minmax(0, 1fr); gap: 16px; margin-top: 16px; }
|
||||
.appearance-preview { width: 140px; margin: auto; }
|
||||
.appearance-preview .fighter-art { max-height: 160px; }
|
||||
.color-options { gap: 6px; }
|
||||
body.player-app .color-options button { padding: 6px 3px 8px; gap: 6px; }
|
||||
.color-swatch { height: 34px; }
|
||||
.color-lock { width: 21px; height: 21px; top: 2px; right: 2px; }
|
||||
.color-name { font-size: 12px; }
|
||||
body.player-app .color-requirement { font-size: 11px; }
|
||||
.leaderboard-content th, .leaderboard-content td { font-size: 12px; padding: 10px 4px; }
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
import { COSMETIC_COLORS, COSMETIC_EQUIPMENT, EYEWEAR_IDS, HAIR_STYLES, SKIN_TONES, type EquipmentId, type HairStyle } from '../../constants/cosmetics.js';
|
||||
import { RANKED_POLL_MS } from '../../constants/ranked.js';
|
||||
import { appearanceKey, normalizeAppearance, type FighterAppearance } from '../shared/cosmetics.js';
|
||||
import type { Leaderboard, PlayerProfile, RankedStatus } from '../shared/ranked-types.js';
|
||||
import type { ActionId } from '../shared/types.js';
|
||||
import type { LocalMatchState } from '../shared/p2p-types.js';
|
||||
import type { GameApi } from './api.js';
|
||||
import { fighterArt } from './fighter-art.js';
|
||||
|
||||
interface LobbyOptions {
|
||||
api: GameApi;
|
||||
onQueue(): void;
|
||||
onMatch(state: LocalMatchState): void;
|
||||
onHome(): void;
|
||||
onAppearance(appearance: FighterAppearance): void;
|
||||
onError(error: unknown): void;
|
||||
onAvailabilityChange(): void;
|
||||
}
|
||||
|
||||
export function createLobbyFeatures(options: LobbyOptions) {
|
||||
const { api } = options;
|
||||
const actions = document.querySelector('.home-actions')!;
|
||||
const rankedStart = document.createElement('button');
|
||||
rankedStart.id = 'ranked-start'; rankedStart.className = 'primary'; rankedStart.disabled = true;
|
||||
rankedStart.innerHTML = '<span>랭킹 대전</span><span aria-hidden="true">→</span>';
|
||||
actions.prepend(rankedStart);
|
||||
document.getElementById('bot-start')!.classList.remove('primary');
|
||||
const tools = document.createElement('div'); tools.className = 'lobby-tools';
|
||||
tools.innerHTML = '<button id="leaderboard-open" disabled>리더보드</button><button id="appearance-open" disabled>캐릭터·장비</button>';
|
||||
actions.after(tools);
|
||||
const queue = document.createElement('section'); queue.id = 'ranked-queue'; queue.className = 'waiting ranked-queue'; queue.hidden = true;
|
||||
queue.innerHTML = '<div class="queue-fighter" aria-hidden="true"></div><h1>대전 상대 찾는 중</h1><button id="ranked-cancel">매칭 취소</button>';
|
||||
document.getElementById('waiting')!.after(queue);
|
||||
const appearanceDialog = document.createElement('dialog'); appearanceDialog.id = 'appearance-dialog';
|
||||
appearanceDialog.innerHTML = '<div class="dialog-top"><h2>캐릭터·장비</h2><button class="quiet" aria-label="캐릭터·장비 닫기">닫기</button></div><div class="appearance-layout"><div class="appearance-preview" aria-label="외형 미리보기"></div><div class="appearance-options"><h3 id="skin-label">피부</h3><div class="skin-options" role="group" aria-labelledby="skin-label"></div><h3 id="hair-label">머리</h3><div class="hair-options" role="group" aria-labelledby="hair-label"></div><h3 id="color-label">도복 색상</h3><div class="color-options" role="group" aria-labelledby="color-label"></div><p class="color-condition" role="status"></p><h3 id="equipment-label">장비</h3><div class="equipment-options" role="group" aria-labelledby="equipment-label"></div><h3 id="eyewear-label" class="eyewear-heading">안경</h3><div class="eyewear-options" role="group" aria-labelledby="eyewear-label"></div><button class="equip primary">장착</button></div></div><p class="profile-notice" role="status"></p>';
|
||||
const leaderboardDialog = document.createElement('dialog'); leaderboardDialog.id = 'leaderboard-dialog';
|
||||
leaderboardDialog.innerHTML = '<div class="dialog-top"><h2>랭킹 리더보드</h2><button class="quiet" aria-label="리더보드 닫기">닫기</button></div><p class="ranking-self"></p><p class="ranking-order">최대 연승순 · 동률이면 누적 승순</p><div class="leaderboard-content" aria-live="polite"></div>';
|
||||
document.body.append(appearanceDialog, leaderboardDialog);
|
||||
const select = <T extends HTMLElement = HTMLElement>(root: ParentNode, selector: string) => root.querySelector<T>(selector)!;
|
||||
const profileNotice = select(appearanceDialog, '.profile-notice');
|
||||
const equip = select<HTMLButtonElement>(appearanceDialog, '.equip');
|
||||
const appearanceOpen = document.getElementById('appearance-open') as HTMLButtonElement;
|
||||
const leaderboardOpen = document.getElementById('leaderboard-open') as HTMLButtonElement;
|
||||
const cancel = document.getElementById('ranked-cancel') as HTMLButtonElement;
|
||||
let profile: PlayerProfile | undefined, preview: FighterAppearance = normalizeAppearance(undefined);
|
||||
let latest: RankedStatus | undefined, active = false, busy = false, profileBusy = false, timer: ReturnType<typeof setTimeout> | undefined, generation = 0;
|
||||
let profileRevision = 0;
|
||||
const lockIcon = '<span class="color-lock" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none"><rect x="5" y="10" width="14" height="11" rx="1" stroke="currentColor" stroke-width="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3m-4 5v2" stroke="currentColor" stroke-width="2"/></svg></span>';
|
||||
const requirementText = (requirement: { type: 'wins' | 'streak'; value: number }) => `랭킹 ${requirement.type === 'wins' ? '누적' : '최대'} ${requirement.value}${requirement.type === 'wins' ? '승' : '연승'}`;
|
||||
|
||||
function updateProfile(next: PlayerProfile) {
|
||||
const appearanceChanged = !profile || appearanceKey(profile.appearance) !== appearanceKey(next.appearance);
|
||||
profile = next;
|
||||
if (appearanceChanged) {
|
||||
options.onAppearance(next.appearance);
|
||||
select(queue, '.queue-fighter').innerHTML = fighterArt(0, false, next.appearance);
|
||||
}
|
||||
options.onAvailabilityChange();
|
||||
}
|
||||
function renderAppearance() {
|
||||
if (!profile) return;
|
||||
select(appearanceDialog, '.appearance-preview').innerHTML = fighterArt(0, false, preview);
|
||||
select(appearanceDialog, '.skin-options').innerHTML = SKIN_TONES.map(skin => `<button type="button" data-skin="${skin.id}" aria-pressed="${preview.skinTone === skin.id}"><span class="skin-swatch" style="--swatch:${skin.skin}" aria-hidden="true"></span>${skin.name}</button>`).join('');
|
||||
select(appearanceDialog, '.hair-options').innerHTML = HAIR_STYLES.map(hair => `<button type="button" data-hair="${hair.id}" aria-pressed="${preview.hairStyle === hair.id}">${hair.name}</button>`).join('');
|
||||
select(appearanceDialog, '.color-options').innerHTML = COSMETIC_COLORS.map(color => {
|
||||
const unlocked = profile!.unlockedColors.includes(color.id);
|
||||
const requirement = color.requirement;
|
||||
const condition = requirement ? requirementText(requirement) : '기본';
|
||||
return `<button type="button" data-color="${color.id}" aria-pressed="${preview.colorId === color.id}" aria-label="${color.name}, ${condition}${unlocked ? '' : ', 잠김, 미리보기'}"><span class="color-swatch" style="--swatch:${color.suit}" aria-hidden="true"></span>${unlocked ? '' : lockIcon}<span class="color-name">${color.name}</span><small class="color-requirement">${condition}</small></button>`;
|
||||
}).join('');
|
||||
const availableEquipment = profile.unlockedEquipment ?? [];
|
||||
const equipmentButton = (item: typeof COSMETIC_EQUIPMENT[number]) => {
|
||||
const selected = preview.equipment!.includes(item.id), unlocked = availableEquipment.includes(item.id);
|
||||
const state = selected ? unlocked ? '착용' : '미리보기' : '미착용';
|
||||
return `<button type="button" data-equipment="${item.id}" aria-pressed="${selected}" aria-label="${item.name}, ${state}, ${requirementText(item.requirement)}${unlocked ? '' : ', 잠김'}">${unlocked ? '' : lockIcon}<span>${item.name}</span><small class="equipment-state">${state}</small><small class="color-requirement">${requirementText(item.requirement)}</small></button>`;
|
||||
};
|
||||
const isEyewear = (id: EquipmentId) => EYEWEAR_IDS.some(eye => eye === id);
|
||||
select(appearanceDialog, '.equipment-options').innerHTML = COSMETIC_EQUIPMENT.filter(item => !isEyewear(item.id)).map(equipmentButton).join('');
|
||||
select(appearanceDialog, '.eyewear-options').innerHTML = `<button type="button" data-equipment="none" aria-pressed="${!preview.equipment!.some(isEyewear)}">미착용</button>`
|
||||
+ COSMETIC_EQUIPMENT.filter(item => isEyewear(item.id)).map(equipmentButton).join('');
|
||||
const color = COSMETIC_COLORS.find(c => c.id === preview.colorId)!;
|
||||
const unlocked = profile.unlockedColors.includes(color.id);
|
||||
const requirement = color.requirement;
|
||||
select(appearanceDialog, '.color-condition').textContent = !unlocked && requirement
|
||||
? `현재 ${requirement.type === 'wins' ? '누적 ' + profile.stats.wins + '승' : '최대 ' + profile.stats.best + '연승'} / ${requirement.value}${requirement.type === 'wins' ? '승' : '연승'}`
|
||||
: requirement ? '획득한 도복' : '기본 도복';
|
||||
const lockedItems = COSMETIC_EQUIPMENT.filter(item => preview.equipment!.includes(item.id) && !availableEquipment.includes(item.id));
|
||||
const equipped = appearanceKey(preview) === appearanceKey(profile.appearance);
|
||||
const canEquip = unlocked && !lockedItems.length;
|
||||
equip.disabled = profileBusy || !canEquip || equipped;
|
||||
equip.textContent = equipped ? '장착 중' : canEquip ? '장착' : '잠김';
|
||||
}
|
||||
select(appearanceDialog, '.skin-options').addEventListener('click', event => {
|
||||
const button = (event.target as HTMLElement).closest<HTMLButtonElement>('[data-skin]');
|
||||
if (!button || profileBusy) return;
|
||||
const skin = SKIN_TONES.find(skin => skin.id === button.dataset.skin);
|
||||
if (!skin) return;
|
||||
preview = { ...preview, skinTone: skin.id }; renderAppearance();
|
||||
});
|
||||
select(appearanceDialog, '.color-options').addEventListener('click', event => {
|
||||
const button = (event.target as HTMLElement).closest<HTMLButtonElement>('[data-color]');
|
||||
if (!button || profileBusy) return;
|
||||
preview = { ...preview, colorId: button.dataset.color! }; renderAppearance();
|
||||
});
|
||||
select(appearanceDialog, '.hair-options').addEventListener('click', event => {
|
||||
const button = (event.target as HTMLElement).closest<HTMLButtonElement>('[data-hair]');
|
||||
if (!button || profileBusy) return;
|
||||
preview = { ...preview, hairStyle: button.dataset.hair as HairStyle }; renderAppearance();
|
||||
});
|
||||
select(appearanceDialog, '.equipment-options').addEventListener('click', event => {
|
||||
const button = (event.target as HTMLElement).closest<HTMLButtonElement>('[data-equipment]');
|
||||
if (!button || profileBusy) return;
|
||||
const id = button.dataset.equipment as EquipmentId;
|
||||
preview = { ...preview, equipment: preview.equipment!.includes(id) ? preview.equipment!.filter(item => item !== id) : [...preview.equipment!, id] }; renderAppearance();
|
||||
});
|
||||
select(appearanceDialog, '.eyewear-options').addEventListener('click', event => {
|
||||
const button = (event.target as HTMLElement).closest<HTMLButtonElement>('[data-equipment]');
|
||||
if (!button || profileBusy) return;
|
||||
const id = button.dataset.equipment;
|
||||
const equipment = preview.equipment!.filter(item => !EYEWEAR_IDS.some(eye => eye === item));
|
||||
const eyewear = EYEWEAR_IDS.find(eye => eye === id);
|
||||
preview = { ...preview, equipment: eyewear ? [...equipment, eyewear] : equipment }; renderAppearance();
|
||||
});
|
||||
async function saveProfile(input: { appearance: FighterAppearance }) {
|
||||
if (profileBusy) return;
|
||||
profileRevision++;
|
||||
profileBusy = true; profileNotice.textContent = ''; renderAppearance();
|
||||
try {
|
||||
updateProfile(await api.request<PlayerProfile>('/api/profile', 'POST', input));
|
||||
profileNotice.textContent = '저장했습니다.';
|
||||
} catch (error) { profileNotice.textContent = error instanceof Error ? error.message : '저장하지 못했습니다.'; }
|
||||
finally { profileBusy = false; renderAppearance(); }
|
||||
}
|
||||
equip.onclick = () => { void saveProfile({ appearance: { ...preview } }); };
|
||||
select<HTMLButtonElement>(appearanceDialog, '.dialog-top button').onclick = () => appearanceDialog.close();
|
||||
select<HTMLButtonElement>(leaderboardDialog, '.dialog-top button').onclick = () => leaderboardDialog.close();
|
||||
appearanceOpen.onclick = async () => {
|
||||
if (!profile) return;
|
||||
appearanceDialog.showModal(); profileNotice.textContent = '';
|
||||
preview = normalizeAppearance(profile.appearance); renderAppearance();
|
||||
const openingPreviewKey = appearanceKey(preview);
|
||||
const revision = ++profileRevision;
|
||||
try {
|
||||
const next = await api.request<PlayerProfile>('/api/profile');
|
||||
if (revision !== profileRevision) return;
|
||||
if (appearanceKey(preview) === openingPreviewKey) preview = normalizeAppearance(next.appearance);
|
||||
updateProfile(next); renderAppearance();
|
||||
} catch (error) { if (revision === profileRevision) profileNotice.textContent = error instanceof Error ? error.message : '프로필을 불러오지 못했습니다.'; }
|
||||
};
|
||||
leaderboardOpen.onclick = async () => {
|
||||
select(leaderboardDialog, '.ranking-self').textContent = profile ? `내 이름 · ${profile.displayName}` : '';
|
||||
const content = select(leaderboardDialog, '.leaderboard-content'); content.textContent = '불러오는 중'; leaderboardDialog.showModal();
|
||||
try {
|
||||
const board = await api.request<Leaderboard>('/api/ranked/leaderboard');
|
||||
content.replaceChildren();
|
||||
if (!board.entries.length) { content.textContent = '아직 랭킹 기록이 없습니다.'; return; }
|
||||
const table = document.createElement('table');
|
||||
table.innerHTML = '<thead><tr><th>순위</th><th>이름</th><th>최대 연승</th><th>누적 승</th></tr></thead><tbody></tbody>';
|
||||
const rows = [...board.entries];
|
||||
if (board.self && !rows.some(row => row.playerId === board.self!.playerId)) rows.push(board.self);
|
||||
for (const entry of rows) {
|
||||
const row = document.createElement('tr');
|
||||
if (entry.playerId === profile?.playerId) row.className = 'self-ranking';
|
||||
for (const text of [entry.rank, entry.displayName + (entry.playerId === profile?.playerId ? ' (나)' : ''), entry.best, entry.wins]) {
|
||||
const cell = document.createElement('td'); cell.textContent = String(text); row.append(cell);
|
||||
}
|
||||
table.tBodies[0].append(row);
|
||||
}
|
||||
content.append(table);
|
||||
} catch (error) { content.textContent = error instanceof Error ? error.message : '랭킹을 불러오지 못했습니다.'; }
|
||||
};
|
||||
function consume(status: RankedStatus) {
|
||||
if (status.match && latest?.match?.matchId === status.match.matchId && status.match.version < latest.match.version) return;
|
||||
latest = status; updateProfile(status.profile);
|
||||
active = status.status !== 'idle'; rankedStart.disabled = busy || active;
|
||||
options.onAvailabilityChange();
|
||||
if (status.status === 'queued') options.onQueue();
|
||||
else if (status.match) options.onMatch(status.match);
|
||||
else options.onHome();
|
||||
}
|
||||
function schedule() {
|
||||
clearTimeout(timer);
|
||||
if (!active || latest?.match?.phase === 'finished') return;
|
||||
const currentGeneration = generation;
|
||||
timer = setTimeout(async () => {
|
||||
try {
|
||||
const status = await api.request<RankedStatus>('/api/ranked/status');
|
||||
if (currentGeneration !== generation) return;
|
||||
consume(status);
|
||||
} catch (error) { if (currentGeneration === generation) options.onError(error); }
|
||||
finally { if (currentGeneration === generation) schedule(); }
|
||||
}, RANKED_POLL_MS);
|
||||
}
|
||||
async function start() {
|
||||
if (busy || active) return;
|
||||
busy = true; rankedStart.disabled = true; generation++;
|
||||
options.onAvailabilityChange();
|
||||
try { consume(await api.request<RankedStatus>('/api/ranked/queue', 'POST', {})); schedule(); }
|
||||
catch (error) { options.onError(error); }
|
||||
finally { busy = false; options.onAvailabilityChange(); }
|
||||
}
|
||||
async function leave() {
|
||||
if (busy) return;
|
||||
busy = true; cancel.disabled = true; generation++; clearTimeout(timer);
|
||||
try {
|
||||
const status = latest?.match
|
||||
? await api.request<RankedStatus>('/api/ranked/leave', 'POST', { matchId: latest.match.matchId })
|
||||
: await api.request<RankedStatus>('/api/ranked/cancel', 'POST', {});
|
||||
consume(status);
|
||||
} catch (error) { options.onError(error); }
|
||||
finally { busy = false; cancel.disabled = false; options.onAvailabilityChange(); schedule(); }
|
||||
}
|
||||
rankedStart.onclick = () => { void start(); };
|
||||
cancel.onclick = () => { void leave(); };
|
||||
return {
|
||||
get active() { return active; },
|
||||
get busy() { return busy; },
|
||||
setReady(ready: boolean) { rankedStart.disabled = !ready || !profile || busy || active; appearanceOpen.disabled = !ready || !profile || busy || active; leaderboardOpen.disabled = !ready || !profile || busy || active; },
|
||||
async initialize(restoreRanked: boolean) {
|
||||
generation++; clearTimeout(timer);
|
||||
if (!restoreRanked) { updateProfile(await api.request<PlayerProfile>('/api/profile')); return false; }
|
||||
const status = await api.request<RankedStatus>('/api/ranked/status');
|
||||
updateProfile(status.profile); latest = status; active = status.status !== 'idle';
|
||||
if (active) { consume(status); schedule(); }
|
||||
return active;
|
||||
},
|
||||
async submitAction(action: ActionId) {
|
||||
if (!latest?.match) return false;
|
||||
const { matchId, turnId } = latest.match;
|
||||
try {
|
||||
const status = await api.request<RankedStatus>('/api/ranked/action', 'POST', { matchId, turnId, action });
|
||||
if (!active || latest?.match?.matchId !== matchId) return false;
|
||||
consume(status); return status.match?.ownAction === action;
|
||||
} catch (error) { options.onError(error); return false; }
|
||||
},
|
||||
leave,
|
||||
async rematch() { await leave(); if (!active) await start(); },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
import { io, type Socket } from 'socket.io-client';
|
||||
import { ACTIONS, ACTION_IDS } from '../../constants/gameplay.js';
|
||||
import { GUEST_TOKEN_KEY } from '../../constants/session.js';
|
||||
import { GAME_CHECKPOINT_KEY, GAME_ROOM_KEY } from '../../constants/recording.js';
|
||||
import { ROOM_POLL_MS } from '../../constants/webrtc.js';
|
||||
import { GAME_NAME } from '../../constants/branding.js';
|
||||
import { HOME_TELEPORT_APPEARANCE } from '../../constants/home.js';
|
||||
import type { Mode, PlayerStats } from '../shared/types.js';
|
||||
import type { ActiveMatch, IceConfiguration, LocalMatchState, PeerRoom } from '../shared/p2p-types.js';
|
||||
import { createClientAnalytics } from './analytics.js';
|
||||
import { ApiClientError, createGameApi } from './api.js';
|
||||
import { createMatchController, type ControllerCheckpoint, type MatchController } from './match-controller.js';
|
||||
import { createPeer } from './peer.js';
|
||||
import { createResultReporter, type SessionStorage } from './result-reporter.js';
|
||||
import { createMatchView } from './match-view.js';
|
||||
import { fighterArt } from './fighter-art.js';
|
||||
import { createLobbyFeatures } from './lobby-features.js';
|
||||
import './game.css';
|
||||
import './match.css';
|
||||
import './home.css';
|
||||
import './ui-theme.css';
|
||||
import './lobby-features.css';
|
||||
|
||||
const el = <T extends HTMLElement = HTMLElement>(id: string) => document.getElementById(id)! as T;
|
||||
document.title = GAME_NAME;
|
||||
const homeFighters = el('home-fighters');
|
||||
homeFighters.innerHTML = '<div class="home-fighter home-fighter-left">' + fighterArt(0) + '</div><div class="home-fighter home-fighter-teleport">' + fighterArt(0, false, HOME_TELEPORT_APPEARANCE) + '</div><div class="home-fighter home-fighter-right">' + fighterArt(1) + '</div>';
|
||||
homeFighters.classList.add('home-entering');
|
||||
homeFighters.addEventListener('animationend', event => {
|
||||
if (event.animationName === 'home-teleport-in') homeFighters.classList.remove('home-entering');
|
||||
});
|
||||
el('waiting-fighter').innerHTML = fighterArt(0);
|
||||
const memory = new Map<string, string>();
|
||||
let storageAvailable = true, ready = false, pending = false, booting = false;
|
||||
let lobby: ReturnType<typeof createLobbyFeatures> | undefined;
|
||||
const storage: SessionStorage = {
|
||||
getItem(key) { try { return sessionStorage.getItem(key) ?? memory.get(key) ?? null; } catch { storageAvailable = false; return memory.get(key) ?? null; } },
|
||||
setItem(key, value) { memory.set(key, value); try { sessionStorage.setItem(key, value); } catch { storageAvailable = false; } },
|
||||
removeItem(key) { memory.delete(key); try { sessionStorage.removeItem(key); } catch { storageAvailable = false; } },
|
||||
};
|
||||
const api = createGameApi(storage);
|
||||
const analytics = createClientAnalytics({
|
||||
events: events => api.request('/api/analytics/events', 'POST', { events }),
|
||||
activity: visible => api.request('/api/analytics/activity', 'POST', { visible }),
|
||||
});
|
||||
let reporter: ReturnType<typeof createResultReporter>;
|
||||
let controller: MatchController | undefined, view: ReturnType<typeof createMatchView> | undefined;
|
||||
let currentMatch: LocalMatchState | undefined, currentRoom: PeerRoom | undefined, currentScreen = '';
|
||||
let waitingSocket: Socket | undefined, roomPoll: ReturnType<typeof setTimeout> | undefined;
|
||||
let startingMatch: string | undefined, startedEvent: string | undefined, finishedEvent: string | undefined;
|
||||
function notice(message = '') { el('notice').textContent = message; }
|
||||
function connection(message: string, connected: boolean) { el('connection').textContent = message; el('connection').classList.toggle('connected', connected); }
|
||||
function controls() {
|
||||
['bot-start', 'room-create', 'join-open'].forEach(id => { el<HTMLButtonElement>(id).disabled = !ready || pending || !!lobby?.busy || !!lobby?.active; });
|
||||
lobby?.setReady(ready && !pending);
|
||||
}
|
||||
function errorNotice(error: unknown) {
|
||||
notice(error instanceof ApiClientError ? error.message : '연결을 확인하고 다시 시도하세요.');
|
||||
el('reconnect').hidden = false;
|
||||
}
|
||||
async function perform(action: () => Promise<void>, event?: string) {
|
||||
if (pending || !ready) return;
|
||||
pending = true; controls(); notice(); if (event) analytics.click(event);
|
||||
try { await action(); } catch (error) { errorNotice(error); }
|
||||
finally { pending = false; controls(); }
|
||||
}
|
||||
function setScreen(screen: 'home' | 'join' | 'waiting' | 'match' | 'result' | 'ranked-queue') {
|
||||
if (currentScreen !== screen) { analytics.screen(screen); currentScreen = screen; notice(); }
|
||||
el('home').hidden = !['home', 'join'].includes(screen); el('waiting').hidden = screen !== 'waiting'; el('match').hidden = !['match', 'result'].includes(screen);
|
||||
el('join-form').hidden = screen !== 'join'; document.body.dataset.screen = screen;
|
||||
el('ranked-queue').hidden = screen !== 'ranked-queue';
|
||||
if (!['match', 'result'].includes(screen)) { view?.destroy(); view = undefined; currentMatch = undefined; }
|
||||
}
|
||||
function stats(data: PlayerStats) {
|
||||
el('stats').innerHTML = (['bot', 'friend'] as const).map(mode => {
|
||||
const s = data[mode];
|
||||
return '<div class="stat-mode"><span>' + (mode === 'bot' ? '봇 대전' : '친구 대전') + '</span><div><span>현재 <strong>' + s.current + '</strong></span><span>최고 <strong>' + s.best + '</strong></span></div></div>';
|
||||
}).join('');
|
||||
}
|
||||
function checkpoint(): ControllerCheckpoint | undefined {
|
||||
try { const saved = JSON.parse(storage.getItem(GAME_CHECKPOINT_KEY) ?? 'null'); return saved?.gameSessionId === api.session.gameSessionId ? saved : undefined; }
|
||||
catch { return undefined; }
|
||||
}
|
||||
function rememberRoom(room?: PeerRoom) {
|
||||
currentRoom = room;
|
||||
if (room) storage.setItem(GAME_ROOM_KEY, JSON.stringify({ gameSessionId: api.session.gameSessionId, room })); else storage.removeItem(GAME_ROOM_KEY);
|
||||
}
|
||||
function stopWaiting() { waitingSocket?.removeAllListeners(); waitingSocket?.disconnect(); waitingSocket = undefined; clearTimeout(roomPoll); }
|
||||
function signaling(room: PeerRoom) {
|
||||
return io('/signal', { autoConnect: false, reconnection: false, forceNew: true, auth: { gameSessionId: api.session.gameSessionId, token: api.session.token, roomId: room.id } });
|
||||
}
|
||||
function showMatch(state: LocalMatchState) {
|
||||
if (state.phase === 'finished' && state.mode === 'friend' && currentRoom?.currentMatchId === state.matchId) {
|
||||
state = { ...state, rematch: [...currentRoom.rematch], opponentLeft: currentRoom.status === 'closed' };
|
||||
}
|
||||
currentMatch = state;
|
||||
if (currentScreen !== 'result' || state.phase !== 'finished') setScreen('match');
|
||||
if (!view) view = createMatchView(el('match'), action => currentMatch?.ranked ? lobby!.submitAction(action) : controller?.submitAction(action) ?? Promise.resolve(false),
|
||||
() => { void perform(() => currentMatch?.ranked ? lobby!.rematch() : controller ? controller.rematch() : rematch(), 'rematch'); },
|
||||
() => { void perform(leave); }, () => setScreen('result'), () => el('rules-open').click());
|
||||
view.update(state);
|
||||
if (state.finished?.saved && !state.ranked) stats(state.finished.stats);
|
||||
if (state.saveStatus === 'failed') el('reconnect').hidden = false;
|
||||
if (state.phase === 'finished') {
|
||||
connection('대전 종료', true);
|
||||
if (finishedEvent !== state.matchId && state.finished) {
|
||||
finishedEvent = state.matchId;
|
||||
analytics.gameFinished(state.matchId, state.mode, state.aborted ? 'aborted' : state.finished.winnerIndex === null ? 'draw' : state.finished.winnerIndex === state.selfIndex ? 'win' : 'loss', state.finished.reason);
|
||||
}
|
||||
} else connection(state.mode === 'friend' && !state.opponentPresent ? '연결 복구 중' : Number.isFinite(state.startsAt) ? '대전 중' : '연결 중', state.opponentPresent && Number.isFinite(state.startsAt));
|
||||
}
|
||||
async function launch(mode: Mode, room?: PeerRoom, existing?: ActiveMatch) {
|
||||
const matchId = existing?.matchId ?? room?.currentMatchId ?? crypto.randomUUID();
|
||||
if (startingMatch === matchId || controller?.view().matchId === matchId) return;
|
||||
startingMatch = matchId; stopWaiting();
|
||||
let active = existing;
|
||||
try {
|
||||
const session = await api.sync();
|
||||
if (!active) {
|
||||
if (session.activeMatch) {
|
||||
if (session.activeMatch.matchId !== matchId) throw new ApiClientError(409, 'MATCH_PENDING', '이전 대전 기록을 저장한 뒤 다시 시도하세요.');
|
||||
active = session.activeMatch;
|
||||
} else {
|
||||
const input = { requestId: crypto.randomUUID(), matchId, mode, ...(room ? { roomId: room.id } : {}), expectedPreviousSeq: session.lastAppliedSeq };
|
||||
try { active = await api.registerMatch(input); }
|
||||
catch (error) { const recovered = await api.sync(); if (recovered.activeMatch?.matchId !== matchId) throw error; active = recovered.activeMatch; }
|
||||
}
|
||||
}
|
||||
controller?.destroy(); controller = undefined; view?.destroy(); view = undefined;
|
||||
const saved = checkpoint();
|
||||
let peer: ReturnType<typeof createPeer> | undefined;
|
||||
const savedFinal = saved?.activeMatch.matchId === matchId && saved.checkpoint.state.phase === 'finished' && (saved.confirmed?.state.phase === 'finished' || saved.checkpoint.state.aborted);
|
||||
if (mode === 'friend' && !savedFinal) {
|
||||
if (!room) throw new Error('ROOM_REQUIRED');
|
||||
const peerRoom = room;
|
||||
const ice = () => api.request<IceConfiguration>('/api/rtc-config?roomId=' + encodeURIComponent(peerRoom.id));
|
||||
peer = createPeer({ matchId, roomId: peerRoom.id, slot: peerRoom.selfIndex,
|
||||
epoch: saved?.activeMatch.matchId === matchId ? saved.epoch : 0,
|
||||
iceServers: (await ice()).iceServers, refreshIce: async () => (await ice()).iceServers,
|
||||
createSignaling: () => signaling(peerRoom) });
|
||||
}
|
||||
controller = createMatchController({ activeMatch: active!, gameSessionId: api.session.gameSessionId, selfIndex: room?.selfIndex ?? 0,
|
||||
reporter, storage, peer, onRematch: rematch });
|
||||
controller.subscribe(showMatch);
|
||||
await controller.start();
|
||||
if (startedEvent !== matchId && !controller.view().aborted) { startedEvent = matchId; analytics.gameStarted(matchId, mode); }
|
||||
el('reconnect').hidden = true;
|
||||
} catch (error) {
|
||||
if (active && !controller) await reporter.submit({ ...active, outcome: 'aborted', reason: 'connection_failed' });
|
||||
throw error;
|
||||
} finally { startingMatch = undefined; }
|
||||
}
|
||||
async function acceptRoom(room: PeerRoom) {
|
||||
rememberRoom(room);
|
||||
if (room.status === 'closed') { stopWaiting(); rememberRoom(); setScreen('home'); notice('방이 종료되었습니다.'); return; }
|
||||
if (room.status === 'paired') { await launch('friend', room); return; }
|
||||
setScreen('waiting'); el('waiting-code').textContent = room.code;
|
||||
el('room-expiry').textContent = new Date(room.expiresAt).toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit' }) + ' 만료';
|
||||
stopWaiting(); waitingSocket = signaling(room);
|
||||
waitingSocket.on('room:state', (next: PeerRoom) => { if (next.status !== 'waiting') void acceptRoom(next).catch(errorNotice); });
|
||||
waitingSocket.on('connect_error', errorNotice);
|
||||
waitingSocket.on('disconnect', () => { connection('연결 끊김', false); el('reconnect').hidden = false; });
|
||||
waitingSocket.connect();
|
||||
}
|
||||
async function rematch() {
|
||||
if (!currentMatch?.finished?.saved) return;
|
||||
if (currentMatch.mode === 'bot') { storage.removeItem(GAME_CHECKPOINT_KEY); await launch('bot'); return; }
|
||||
if (!currentRoom) return;
|
||||
const room = currentRoom;
|
||||
const next = await api.request<PeerRoom>('/api/rooms/' + room.id + '/rematch', 'POST', { round: room.round, requestId: crypto.randomUUID() });
|
||||
rememberRoom(next);
|
||||
if (currentMatch) showMatch(currentMatch);
|
||||
if (next.round > room.round) { storage.removeItem(GAME_CHECKPOINT_KEY); await launch('friend', next); return; }
|
||||
watchRematch(room);
|
||||
}
|
||||
function watchRematch(room: PeerRoom) {
|
||||
clearTimeout(roomPoll);
|
||||
const poll = async () => {
|
||||
if (currentRoom?.id !== room.id) return;
|
||||
try {
|
||||
const updated = await api.request<PeerRoom>('/api/rooms/' + room.id);
|
||||
rememberRoom(updated);
|
||||
if (currentMatch) showMatch(currentMatch);
|
||||
if (updated.status === 'closed') return;
|
||||
if (updated.round > room.round) { storage.removeItem(GAME_CHECKPOINT_KEY); await launch('friend', updated); return; }
|
||||
} catch (error) { errorNotice(error); if (error instanceof ApiClientError && error.status < 500) return; }
|
||||
roomPoll = setTimeout(() => { void poll(); }, ROOM_POLL_MS);
|
||||
};
|
||||
roomPoll = setTimeout(() => { void poll(); }, ROOM_POLL_MS);
|
||||
}
|
||||
async function leave() {
|
||||
if (currentMatch?.ranked || lobby?.active) { await lobby!.leave(); return; }
|
||||
stopWaiting();
|
||||
if (controller && controller.view().phase !== 'finished') await controller.leave();
|
||||
if (currentRoom) await api.leaveRoom(currentRoom.id);
|
||||
controller?.destroy(); controller = undefined; rememberRoom(); storage.removeItem(GAME_CHECKPOINT_KEY);
|
||||
stats((await api.sync()).stats); setScreen('home'); connection('접속됨', true);
|
||||
}
|
||||
async function boot() {
|
||||
if (booting) return; booting = true; ready = false; controls(); connection('접속 중', false);
|
||||
try {
|
||||
let token: string | undefined;
|
||||
try { token = localStorage.getItem(GUEST_TOKEN_KEY) ?? undefined; } catch { storageAvailable = false; }
|
||||
const session = await api.ensureSession(token);
|
||||
if (session.guestToken) try { localStorage.setItem(GUEST_TOKEN_KEY, session.guestToken); } catch { storageAvailable = false; }
|
||||
reporter?.destroy(); reporter = createResultReporter({ gameSessionId: session.gameSessionId, record: value => api.record(value) }, storage);
|
||||
analytics.ready(); stats(session.stats); connection('접속됨', true); el('reconnect').hidden = true;
|
||||
await reporter.resumePending(); await api.sync();
|
||||
if (await api.resumeRoomLeave()) { rememberRoom(); storage.removeItem(GAME_CHECKPOINT_KEY); }
|
||||
const saved = checkpoint();
|
||||
try { const stored = JSON.parse(storage.getItem(GAME_ROOM_KEY) ?? 'null'); if (stored?.gameSessionId === session.gameSessionId) currentRoom = stored.room; } catch { rememberRoom(); }
|
||||
const active = api.session.activeMatch;
|
||||
try {
|
||||
if (await lobby?.initialize(!active && !currentRoom)) { ready = true; return; }
|
||||
} catch (error) { errorNotice(error); }
|
||||
if (active) {
|
||||
if (active.mode === 'friend' && saved?.activeMatch.matchId !== active.matchId) {
|
||||
await reporter.submit({ ...active, outcome: 'aborted', reason: 'connection_failed' });
|
||||
if (active.roomId) await api.leaveRoom(active.roomId);
|
||||
rememberRoom();
|
||||
} else if (active.mode === 'friend') {
|
||||
try { rememberRoom(await api.request<PeerRoom>('/api/rooms/' + active.roomId)); }
|
||||
catch (error) {
|
||||
if (!(error instanceof ApiClientError) || ![404, 410].includes(error.status)) throw error;
|
||||
await reporter.submit({ ...active, outcome: 'aborted', reason: 'connection_failed' }); rememberRoom();
|
||||
}
|
||||
}
|
||||
if (api.session.activeMatch) await launch(active.mode, currentRoom, active);
|
||||
else { stats(api.session.stats); setScreen('home'); }
|
||||
} else if (saved?.checkpoint.state.phase === 'finished') {
|
||||
const state = saved.checkpoint.state;
|
||||
state.appearances = saved.activeMatch.appearances;
|
||||
state.saveStatus = 'saved'; state.finished!.saved = true; state.finished!.stats = api.session.stats;
|
||||
state.clockNow = Date.now(); state.reveal = undefined; state.finished!.revealEndsAt = state.clockNow;
|
||||
state.opponentPresent = state.mode === 'bot' || !!currentRoom;
|
||||
showMatch(state);
|
||||
if (state.mode === 'friend' && currentRoom) {
|
||||
try {
|
||||
const room = await api.request<PeerRoom>('/api/rooms/' + currentRoom.id); rememberRoom(room);
|
||||
if (room.status === 'paired' && room.currentMatchId !== state.matchId) { storage.removeItem(GAME_CHECKPOINT_KEY); await launch('friend', room); }
|
||||
else { showMatch(state); if (room.status === 'paired' && room.rematch[room.selfIndex]) watchRematch(room); }
|
||||
} catch (error) {
|
||||
if (!(error instanceof ApiClientError) || ![404, 410].includes(error.status)) throw error;
|
||||
rememberRoom(); state.opponentLeft = true; showMatch(state);
|
||||
}
|
||||
}
|
||||
} else if (currentRoom) {
|
||||
try { await acceptRoom(await api.request<PeerRoom>('/api/rooms/' + currentRoom.id)); }
|
||||
catch (error) { rememberRoom(); setScreen('home'); errorNotice(error); }
|
||||
} else setScreen(new URLSearchParams(location.search).has('room') ? 'join' : 'home');
|
||||
ready = true;
|
||||
if (!storageAvailable) { notice('저장소를 사용할 수 없어 새로고침 시 기록을 복원할 수 없습니다.'); analytics.error('storage_unavailable', currentScreen || 'home'); }
|
||||
} catch (error) { errorNotice(error); }
|
||||
finally { booting = false; controls(); }
|
||||
}
|
||||
el('bot-start').onclick = () => { void perform(() => launch('bot'), 'bot_start'); };
|
||||
el('room-create').onclick = () => { void perform(async () => acceptRoom(await api.createRoom()), 'room_create'); };
|
||||
el('join-open').onclick = () => { setScreen('join'); el<HTMLInputElement>('room-code').focus(); };
|
||||
el('join-close').onclick = () => setScreen('home');
|
||||
el('join-form').onsubmit = e => { e.preventDefault(); void perform(async () => acceptRoom(await api.request<PeerRoom>('/api/rooms/join', 'POST', { code: el<HTMLInputElement>('room-code').value.trim().toUpperCase(), requestId: crypto.randomUUID() })), 'room_join'); };
|
||||
el('waiting-leave').onclick = () => { void perform(leave); };
|
||||
el('reconnect').onclick = () => {
|
||||
if (controller?.view().phase === 'finished') { void controller.retrySave(); return; }
|
||||
if (controller) return;
|
||||
void boot();
|
||||
};
|
||||
async function copy(link: boolean) {
|
||||
if (!currentRoom) return;
|
||||
const url = new URL(location.origin); url.searchParams.set('room', currentRoom.code);
|
||||
try { await navigator.clipboard.writeText(link ? url.toString() : currentRoom.code); notice('복사했습니다.'); }
|
||||
catch { notice('복사하지 못했습니다. 표시된 코드를 전달하세요.'); }
|
||||
}
|
||||
el('copy-code').onclick = () => { void copy(false); }; el('copy-link').onclick = () => { void copy(true); };
|
||||
el('rules-list').innerHTML = ACTION_IDS.map(id => '<div class="rule-row"><strong>' + ACTIONS[id].name + '</strong><span>기 ' + ACTIONS[id].cost + ' 소모 · ' + ACTIONS[id].effect + '</span></div>').join('');
|
||||
for (const name of ['rules', 'privacy', 'contact']) el(name + '-open').onclick = () => el<HTMLDialogElement>(name + '-dialog').showModal();
|
||||
document.querySelectorAll<HTMLButtonElement>('[data-close]').forEach(b => { b.onclick = () => b.closest('dialog')!.close(); });
|
||||
const invitation = new URLSearchParams(location.search).get('room');
|
||||
if (invitation && /^[A-Z0-9]{6}$/i.test(invitation)) el<HTMLInputElement>('room-code').value = invitation.toUpperCase();
|
||||
lobby = createLobbyFeatures({
|
||||
api,
|
||||
onQueue: () => { setScreen('ranked-queue'); connection('상대 찾는 중', true); },
|
||||
onMatch: state => showMatch(state),
|
||||
onHome: () => { setScreen('home'); connection('접속됨', true); notice(); el('reconnect').hidden = true; },
|
||||
onAppearance: appearance => {
|
||||
el('waiting-fighter').innerHTML = fighterArt(0, false, appearance);
|
||||
},
|
||||
onError: errorNotice,
|
||||
onAvailabilityChange: controls,
|
||||
});
|
||||
void boot();
|
||||
@@ -0,0 +1,90 @@
|
||||
import { GAME_CHECKPOINT_KEY } from '../../constants/recording.js';
|
||||
import { createMatchEngine } from '../shared/match-engine.js';
|
||||
import type { ActiveMatch, LocalMatchState, MatchCheckpoint, RecordAck, ResultSubmission } from '../shared/p2p-types.js';
|
||||
import type { ActionId } from '../shared/types.js';
|
||||
import type { SessionStorage } from './result-reporter.js';
|
||||
import { createFriendMatch, type GamePeer } from './friend-match.js';
|
||||
|
||||
export interface GameClock { now(): number; setTimeout(fn: () => void, ms: number): any; clearTimeout(id: any): void }
|
||||
export interface ControllerConfig {
|
||||
activeMatch: ActiveMatch; gameSessionId: string; selfIndex: 0 | 1;
|
||||
reporter: { submit(data: ResultSubmission): Promise<RecordAck> }; storage: SessionStorage;
|
||||
clock?: GameClock; random?: () => number; onRematch?: () => Promise<void>;
|
||||
peer?: GamePeer;
|
||||
}
|
||||
export interface ControllerCheckpoint { gameSessionId: string; activeMatch: ActiveMatch; checkpoint: MatchCheckpoint; epoch: number; confirmed?: MatchCheckpoint }
|
||||
const defaultClock: GameClock = { now: Date.now, setTimeout: (fn, ms) => setTimeout(fn, ms), clearTimeout: id => clearTimeout(id) };
|
||||
export function createMatchController(config: ControllerConfig) {
|
||||
const clock = config.clock ?? defaultClock;
|
||||
let offset = 0;
|
||||
const engine = createMatchEngine({ ...config.activeMatch, selfIndex: config.selfIndex, clock: { now: () => clock.now() + offset }, random: config.random });
|
||||
let friend: ReturnType<typeof createFriendMatch> | undefined;
|
||||
const listeners = new Set<(state: LocalMatchState) => void>();
|
||||
let timer: any, destroyed = false, started = false, recording = false;
|
||||
function publish() {
|
||||
if (destroyed) return;
|
||||
const checkpoint: ControllerCheckpoint = { gameSessionId: config.gameSessionId, activeMatch: config.activeMatch, checkpoint: engine.checkpoint(), epoch: 0, ...friend?.snapshot() };
|
||||
config.storage.setItem(GAME_CHECKPOINT_KEY, JSON.stringify(checkpoint));
|
||||
for (const listener of listeners) listener({ ...engine.view(), appearances: config.activeMatch.appearances });
|
||||
}
|
||||
async function record() {
|
||||
const state = engine.view(); if (!state.finished || state.finished.saved || recording || destroyed) return;
|
||||
recording = true;
|
||||
const outcome = state.aborted ? 'aborted' : state.finished.winnerIndex === null ? 'draw' : state.finished.winnerIndex === config.selfIndex ? 'win' : 'loss';
|
||||
try {
|
||||
const ack = await config.reporter.submit({ ...config.activeMatch, outcome, reason: state.finished.reason });
|
||||
if (!destroyed) { engine.saved(ack.stats); publish(); }
|
||||
} catch { if (!destroyed) { engine.saveFailed(); publish(); } }
|
||||
finally { recording = false; }
|
||||
}
|
||||
function tick() {
|
||||
if (destroyed) return;
|
||||
clock.clearTimeout(timer);
|
||||
let state = engine.view();
|
||||
if (state.phase === 'countdown' && clock.now() >= state.startsAt) engine.beginTurn(state.startsAt);
|
||||
else if (state.phase === 'revealing' && clock.now() >= state.reveal!.revealEndsAt) engine.beginTurn(state.reveal!.revealEndsAt);
|
||||
state = engine.view();
|
||||
if (state.phase === 'selecting' && clock.now() >= state.deadline) engine.resolve([state.ownAction, engine.botAction()]);
|
||||
publish(); state = engine.view();
|
||||
if (state.phase === 'finished') { void record(); return; }
|
||||
const next = state.phase === 'countdown' ? state.startsAt : state.phase === 'selecting' ? state.deadline : state.reveal!.revealEndsAt;
|
||||
timer = clock.setTimeout(tick, Math.max(0, next - clock.now()));
|
||||
}
|
||||
return {
|
||||
async start() {
|
||||
if (started) return; started = true;
|
||||
let saved: ControllerCheckpoint | undefined;
|
||||
try { saved = JSON.parse(config.storage.getItem(GAME_CHECKPOINT_KEY) ?? 'null') ?? undefined; } catch { /* new game */ }
|
||||
if (saved?.gameSessionId !== config.gameSessionId || saved.activeMatch.matchId !== config.activeMatch.matchId) saved = undefined;
|
||||
if (saved) engine.restore(saved.checkpoint);
|
||||
if (saved?.checkpoint.state.phase === 'finished' && (config.activeMatch.mode === 'bot' || saved.confirmed?.state.phase === 'finished' || saved.checkpoint.state.aborted)) {
|
||||
publish(); await record(); return;
|
||||
}
|
||||
if (config.activeMatch.mode === 'friend') {
|
||||
if (!config.peer) throw new Error('PEER_REQUIRED');
|
||||
friend = createFriendMatch({ matchId: config.activeMatch.matchId, slot: config.selfIndex, engine, peer: config.peer, clock,
|
||||
setOffset: value => { offset = value; }, changed: publish, finished: () => { void record(); }, saved });
|
||||
await friend.start(); return;
|
||||
}
|
||||
if (!saved) engine.start();
|
||||
tick();
|
||||
},
|
||||
async submitAction(action: ActionId) { if (friend?.recovering) return false; const accepted = engine.lockAction(action); if (accepted) publish(); return accepted; },
|
||||
async leave() {
|
||||
if (friend) { await friend.leave(); await record(); return; }
|
||||
clock.clearTimeout(timer);
|
||||
if (engine.view().phase !== 'finished') engine.finish(config.selfIndex === 0 ? 1 : 0, 'forfeit');
|
||||
publish(); await record();
|
||||
},
|
||||
async rematch() {
|
||||
if (!engine.view().finished?.saved) return;
|
||||
await config.onRematch?.();
|
||||
if (!destroyed) friend?.requestRematch();
|
||||
},
|
||||
view: () => ({ ...engine.view(), appearances: config.activeMatch.appearances }),
|
||||
subscribe(listener: (state: LocalMatchState) => void) { listeners.add(listener); listener({ ...engine.view(), appearances: config.activeMatch.appearances }); return () => { listeners.delete(listener); }; },
|
||||
retrySave: record,
|
||||
destroy() { destroyed = true; clock.clearTimeout(timer); friend?.destroy(); listeners.clear(); },
|
||||
};
|
||||
}
|
||||
export type MatchController = ReturnType<typeof createMatchController>;
|
||||
@@ -0,0 +1,142 @@
|
||||
import { ACTIONS, ACTION_IDS, MATCH_COUNTDOWN_MS, TURN_DURATION_MS } from '../../constants/gameplay.js';
|
||||
import type { ActionId } from '../shared/types.js';
|
||||
import type { LocalMatchState } from '../shared/p2p-types.js';
|
||||
import { appearanceKey, type FighterAppearance } from '../shared/cosmetics.js';
|
||||
import { createActionRenderer } from './action-renderer.js';
|
||||
import { actionIcon, ashPileArt, checkIcon, fighterArt } from './fighter-art.js';
|
||||
import { createFinishCutscene } from './finish-cutscene.js';
|
||||
|
||||
const fighter = (i: number, appearance?: FighterAppearance) => `<div class="fighter" data-fighter="${i}">
|
||||
<div class="avatar"><div class="aura"></div><div class="motion-trails"><i></i><i></i><i></i></div><div class="character-direction">${fighterArt(i, false, appearance)}</div>${ashPileArt()}<div class="projectile"></div><div class="shield"></div><div class="impact"></div></div>
|
||||
<div class="fighter-caption"><span class="choice-status"></span><span class="action-label"></span><span class="energy-change" aria-hidden="true"></span></div>
|
||||
</div>`;
|
||||
|
||||
export function createMatchView(container: HTMLElement, onAction: (action: ActionId) => Promise<boolean>, onRematch: () => void, onLeave: () => void, onResult: () => void = () => {}, onRules: () => void = () => {}, appearances?: [FighterAppearance, FighterAppearance]) {
|
||||
container.innerHTML = `<div class="match-top"><div class="match-title"><span class="mode-label"></span><h1 class="turn-heading"></h1></div><details class="match-menu"><summary aria-label="대전 메뉴">메뉴</summary><div class="match-menu-items"><button class="match-rules quiet">규칙</button><button class="leave quiet">나가기</button></div></details></div>
|
||||
<div class="timing"><span class="selection-status" role="status"></span><span class="time-label" aria-label="남은 시간"></span><div class="time-track" role="progressbar" aria-label="남은 선택 시간" aria-valuemin="0" aria-valuemax="100"><span></span></div></div>
|
||||
<div class="arena"><div class="arena-floor" aria-hidden="true"></div><div class="result-banner" hidden><h2></h2><p class="finish-reason"></p></div>${fighter(0, appearances?.[0])}<div class="duel-cue"><span class="versus" aria-hidden="true">VS</span><strong class="countdown-number" role="status"></strong></div><span class="clash-label" role="status"></span><div class="clash-burst" aria-hidden="true"></div>${fighter(1, appearances?.[1])}</div>
|
||||
<div class="player-controls" role="group" aria-label="내 행동 선택"><div class="energy">${actionIcon('charge')}<strong class="energy-count"></strong></div><div class="action-buttons">${ACTION_IDS.map(id => `<button data-action="${id}" aria-label="${ACTIONS[id].name}, ${ACTIONS[id].effect}${ACTIONS[id].cost ? `, 기 ${ACTIONS[id].cost} 소모` : ''}"><span class="action-symbol">${actionIcon(id)}</span><strong>${ACTIONS[id].name}</strong><small>${id === 'charge' || id === 'guard' ? ACTIONS[id].effect : `기 ${ACTIONS[id].cost} 소모`}</small><span class="chosen-mark">${checkIcon}</span></button>`).join('')}</div></div>
|
||||
<div class="match-result" hidden><div class="result-stats"></div><p class="save-status" role="status"></p><div class="split"><button class="rematch primary">다시 대전</button><button class="home-return">시작 화면</button></div><p class="rematch-status" role="status"></p></div>`;
|
||||
const arena = container.querySelector<HTMLElement>('.arena')!;
|
||||
const renderer = createActionRenderer(arena);
|
||||
const cutscene = createFinishCutscene(() => { tick(); el('.result-banner h2').focus({ preventScroll: true }); }, appearances);
|
||||
const appearanceSignatures = [0, 1].map(i => appearanceKey(appearances?.[i]));
|
||||
let current: LocalMatchState | undefined, offset = 0, busy = false, lastId = '', resultReported = '', submission = 0;
|
||||
const el = <T extends HTMLElement = HTMLElement>(selector: string) => container.querySelector<T>(selector)!;
|
||||
el('.result-banner h2').tabIndex = -1;
|
||||
const buttons = Array.from(container.querySelectorAll<HTMLButtonElement>('[data-action]'));
|
||||
buttons.forEach(button => button.addEventListener('click', async () => {
|
||||
if (busy || !current || current.phase !== 'selecting' || current.ownAction || Date.now() + offset >= current.deadline) return;
|
||||
const action = button.dataset.action as ActionId;
|
||||
if (current.energy[current.selfIndex] < ACTIONS[action].cost) return;
|
||||
busy = true; tick();
|
||||
const submittedMatch = current.matchId, submittedTurn = current.turnId, request = ++submission;
|
||||
try {
|
||||
const accepted = await onAction(action);
|
||||
if (request !== submission || !current) return;
|
||||
if (accepted && current.matchId === submittedMatch && current.turnId === submittedTurn && current.phase === 'selecting' && !current.ownAction) current.ownAction = action;
|
||||
} finally {
|
||||
if (request === submission) { busy = false; tick(); }
|
||||
}
|
||||
}));
|
||||
el('.leave').onclick = onLeave; el('.home-return').onclick = onLeave; el('.rematch').onclick = onRematch;
|
||||
el('.match-rules').onclick = () => {
|
||||
el<HTMLDetailsElement>('.match-menu').open = false;
|
||||
el('.match-menu summary').focus();
|
||||
onRules();
|
||||
};
|
||||
function tick() {
|
||||
if (!current) return;
|
||||
const now = Date.now() + offset, s = current;
|
||||
const revealing = !!s.reveal && now < s.reveal.revealEndsAt;
|
||||
const resultVisible = s.phase === 'finished' && !revealing;
|
||||
const phase = revealing ? 'revealing' : resultVisible ? 'finished' : s.phase;
|
||||
container.dataset.phase = phase;
|
||||
container.dataset.locked = String(!!s.ownAction);
|
||||
const finishing = arena.dataset.outcome === 'hit';
|
||||
el('.player-controls').hidden = finishing || resultVisible;
|
||||
el('.timing').hidden = finishing || resultVisible;
|
||||
el('.match-result').hidden = !resultVisible;
|
||||
el('.result-banner').hidden = !resultVisible;
|
||||
el('.mode-label').textContent = s.ranked ? '랭킹 대전' : s.mode === 'bot' ? '봇 대전' : '친구 대전';
|
||||
el('.rematch').textContent = s.ranked ? '다시 매칭' : '다시 대전';
|
||||
el('.turn-heading').textContent = s.phase === 'countdown' ? '대전 준비' : resultVisible ? '대전 종료' : `${s.turnId} 턴`;
|
||||
const remaining = s.phase === 'countdown' ? Math.max(0, s.startsAt - now) : Math.max(0, s.deadline - now);
|
||||
const count = Number.isFinite(remaining) ? String(Math.ceil(remaining / 1000)) : '';
|
||||
const countdown = el('.countdown-number');
|
||||
if (s.phase === 'countdown' && countdown.textContent !== count) { countdown.textContent = count; countdown.dataset.count = count; }
|
||||
el('.duel-cue').hidden = revealing || resultVisible;
|
||||
el('.selection-status').textContent = !resultVisible && !s.opponentPresent ? '연결 복구 중' : s.phase === 'countdown' ? Number.isFinite(remaining) ? '' : '상대 연결 중' : revealing ? '동시 공개' : resultVisible ? '' : busy ? '선택 중' : s.ownAction ? '선택 완료' : remaining > 0 ? '행동 선택' : '결과 대기';
|
||||
el('.time-label').textContent = s.phase === 'selecting' && !revealing ? `${(remaining / 1000).toFixed(1)}초` : '';
|
||||
const progress = s.phase === 'countdown' ? remaining / MATCH_COUNTDOWN_MS : s.phase === 'selecting' ? remaining / TURN_DURATION_MS : 0;
|
||||
el('.time-track').style.setProperty('--time', String(Math.min(1, progress)));
|
||||
el('.time-track').setAttribute('aria-valuenow', String(Math.round(Math.min(1, progress) * 100)));
|
||||
el('.time-track').setAttribute('aria-label', s.phase === 'countdown' ? '대전 시작까지 남은 시간' : '남은 선택 시간');
|
||||
el('.timing').dataset.urgent = String(s.phase === 'selecting' && remaining <= 1000 && !s.ownAction);
|
||||
buttons.forEach(button => {
|
||||
const id = button.dataset.action as ActionId;
|
||||
const affordable = s.energy[s.selfIndex] >= ACTIONS[id].cost;
|
||||
button.disabled = busy || !s.opponentPresent || s.phase !== 'selecting' || !!s.ownAction || now < s.startsAt || now >= s.deadline || !affordable;
|
||||
button.dataset.unavailable = String(!affordable);
|
||||
button.classList.toggle('chosen', s.ownAction === id); button.setAttribute('aria-pressed', String(s.ownAction === id));
|
||||
button.title = !affordable ? `기 ${ACTIONS[id].cost} 필요 · ${ACTIONS[id].effect}` : ACTIONS[id].effect;
|
||||
button.querySelector('small')!.textContent = !affordable ? `기 ${ACTIONS[id].cost} 필요` : id === 'charge' || id === 'guard' ? ACTIONS[id].effect : `기 ${ACTIONS[id].cost} 소모`;
|
||||
});
|
||||
container.querySelectorAll<HTMLElement>('[data-fighter]').forEach((node, i) => {
|
||||
const self = i === s.selfIndex;
|
||||
node.dataset.locked = String(self && !!s.ownAction && phase === 'selecting');
|
||||
if (resultVisible && !s.aborted && s.finished?.reason === 'hit' && s.reveal?.matchId === s.matchId
|
||||
&& s.reveal.hitIndex === i && s.reveal.actions[1 - i] === 'beam') node.dataset.ash = 'true';
|
||||
else delete node.dataset.ash;
|
||||
node.querySelector('.choice-status')!.textContent = phase === 'selecting' ? self && s.ownAction ? `${ACTIONS[s.ownAction].name} · 선택 완료` : !self && !s.opponentPresent ? '연결 끊김' : '' : '';
|
||||
if (resultVisible && s.finished && !s.aborted && !finishing) node.dataset.reaction = s.finished.winnerIndex === null ? 'draw' : s.finished.winnerIndex === i ? 'win' : 'loss';
|
||||
else delete node.dataset.reaction;
|
||||
});
|
||||
if (resultVisible && s.finished) {
|
||||
if (resultReported !== s.matchId) { resultReported = s.matchId; onResult(); }
|
||||
const f = s.finished;
|
||||
const outcome = s.aborted ? 'aborted' : f.winnerIndex === null ? 'draw' : f.winnerIndex === s.selfIndex ? 'win' : 'loss';
|
||||
container.dataset.outcome = outcome;
|
||||
arena.dataset.result = outcome;
|
||||
el('.result-banner h2').textContent = ({ win: '승리!', loss: '패배', draw: '무승부', aborted: '대전 중단' })[outcome];
|
||||
el('.match-result').dataset.result = outcome;
|
||||
el('.finish-reason').textContent = ({ hit: '명중', timeout: '선택 시간 초과', double_timeout: '양쪽 선택 시간 초과', forfeit: '기권', disconnect: '연결 종료', double_disconnect: '양쪽 연결 종료', connection_failed: '연결을 복구하지 못했습니다.', protocol_error: '대전 상태를 맞추지 못했습니다.' })[f.reason];
|
||||
const stats = f.stats[s.mode];
|
||||
el('.result-stats').textContent = f.saved ? `현재 연승 ${stats.current} · 최고 연승 ${stats.best}` : '';
|
||||
el('.save-status').textContent = s.saveStatus === 'failed' ? '기록을 저장하지 못했습니다. 다시 시도하세요.' : f.saved ? '' : '기록 저장 중';
|
||||
el<HTMLButtonElement>('.rematch').disabled = !f.saved || (!s.ranked && (!!s.rematch[s.selfIndex] || s.opponentLeft));
|
||||
el('.rematch-status').textContent = s.ranked ? '' : s.opponentLeft ? '상대가 나갔습니다.' : s.rematch[s.selfIndex] ? '상대의 재대전 동의 대기' : s.rematch[s.selfIndex === 0 ? 1 : 0] ? '상대가 재대전을 요청했습니다.' : '';
|
||||
} else {
|
||||
delete arena.dataset.result;
|
||||
if (arena.dataset.outcome) container.dataset.outcome = arena.dataset.outcome;
|
||||
else delete container.dataset.outcome;
|
||||
}
|
||||
}
|
||||
const interval = setInterval(tick, 100);
|
||||
return {
|
||||
update(state: LocalMatchState) {
|
||||
if (current?.matchId === state.matchId && current.version > state.version) return;
|
||||
if (lastId !== state.matchId) { cutscene.reset(); renderer.reset(); lastId = state.matchId; busy = false; submission++; }
|
||||
if (current && current.turnId !== state.turnId) { busy = false; submission++; }
|
||||
current = state; offset = state.clockNow - Date.now();
|
||||
container.dataset.self = String(state.selfIndex);
|
||||
el('.energy-count').textContent = String(state.energy[state.selfIndex]);
|
||||
el('.energy').setAttribute('aria-label', `내 기 ${state.energy[state.selfIndex]}`);
|
||||
container.querySelectorAll<HTMLElement>('[data-fighter]').forEach((node, i) => {
|
||||
const appearance = (state.appearances ?? appearances)?.[i];
|
||||
const signature = appearanceKey(appearance);
|
||||
if (appearanceSignatures[i] !== signature) {
|
||||
node.querySelector('.character-direction')!.innerHTML = fighterArt(i, false, appearance);
|
||||
appearanceSignatures[i] = signature;
|
||||
}
|
||||
node.classList.toggle('self', i === state.selfIndex);
|
||||
node.setAttribute('role', 'group');
|
||||
node.setAttribute('aria-label', i === state.selfIndex ? '내 캐릭터' : state.mode === 'bot' ? '봇 캐릭터' : '상대 캐릭터');
|
||||
});
|
||||
if (state.reveal && !state.aborted && (!state.finished || state.finished.reason === 'hit')) renderer.play(state.reveal, Date.now() + offset);
|
||||
else renderer.reset();
|
||||
tick();
|
||||
cutscene.update(state);
|
||||
}, destroy() { submission++; clearInterval(interval); cutscene.destroy(); renderer.destroy(); container.innerHTML = ''; current = undefined; },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
/* Match layout only; character poses, projectiles and finish cutscenes stay in game.css. */
|
||||
body:is([data-screen=match], [data-screen=result]) {
|
||||
overflow: hidden;
|
||||
background: #d9ddd4;
|
||||
}
|
||||
body:is([data-screen=match], [data-screen=result]) .app {
|
||||
width: 100%; max-width: none; height: 100dvh; min-height: 0; padding: 0;
|
||||
}
|
||||
body:is([data-screen=match], [data-screen=result]) .app > :is(header, footer) { display: none; }
|
||||
body:is([data-screen=match], [data-screen=result]) main { padding: 0; min-height: 0; }
|
||||
#match {
|
||||
--stage-top: calc(120px + env(safe-area-inset-top));
|
||||
--stage-bottom: 24px;
|
||||
--fighter-size: min(32vw, calc((100cqh - 38px) * .866667), 460px);
|
||||
--edge: clamp(20px, 3vw, 56px);
|
||||
--self-position: 26.63%;
|
||||
position: relative; isolation: isolate; flex: 1; width: 100%; max-width: none;
|
||||
min-height: 0; height: 100%; margin: 0;
|
||||
display: grid; grid-template-rows: minmax(0, 1fr) auto; overflow: hidden;
|
||||
background: #bebda9;
|
||||
}
|
||||
#match[data-self="1"] { --self-position: 73.37%; }
|
||||
#match[data-phase=finished] { --stage-top: calc(260px + env(safe-area-inset-top)); }
|
||||
#match .arena, #match[data-phase=finished] .arena {
|
||||
--floor-line: calc(100% - var(--stage-bottom) - 80px);
|
||||
position: relative; grid-area: 1 / 1; container-type: size;
|
||||
min-height: 0; border: 0; border-radius: 0; overflow: visible;
|
||||
padding: var(--stage-top) 9vw var(--stage-bottom);
|
||||
align-items: flex-end;
|
||||
background: linear-gradient(180deg, #d6dfd9 0%, #e9e6d9 var(--floor-line), #d0cbb7 var(--floor-line), #bebda9 100%);
|
||||
}
|
||||
#match .arena::before {
|
||||
inset: 0; border: 0; transform: none;
|
||||
background: radial-gradient(ellipse at 50% 38%, #fff9e966, transparent 65%);
|
||||
}
|
||||
#match .arena::after {
|
||||
inset: var(--floor-line) -25% -100dvh; width: 150%; height: auto; border: 0; border-radius: 0;
|
||||
background: repeating-linear-gradient(174deg, transparent 0 55px, #60695512 56px 57px, transparent 58px 100px);
|
||||
}
|
||||
#match .arena-floor {
|
||||
inset: var(--floor-line) 0 0; width: 100%; height: auto; border-radius: 0;
|
||||
background: transparent; border-top: 2px solid #69756838;
|
||||
}
|
||||
/* Fighter slots stay fixed so effects and the controls share the same coordinates. */
|
||||
#match .fighter { position: static; width: 43%; }
|
||||
#match .avatar, #match[data-phase=finished] .avatar {
|
||||
width: var(--fighter-size);
|
||||
max-width: none; margin: 0; left: 50%; translate: -50% 0;
|
||||
}
|
||||
#match .fighter-caption { height: 30px; margin: 8px 0 0; }
|
||||
#match .energy { min-width: 0; border: 0; padding: 0 0 8px; background: transparent; gap: 6px; justify-content: center; }
|
||||
#match .energy-count { font-size: 24px; font-weight: 900; }
|
||||
#match .energy > svg { width: 22px; height: 22px; color: #8c633e; }
|
||||
#match .energy-change { position: static; margin-left: 6px; font-size: 18px; }
|
||||
#match .match-top { position: absolute; inset: 0; z-index: 15; margin: 0; pointer-events: none; }
|
||||
#match .match-title {
|
||||
position: absolute; top: calc(24px + env(safe-area-inset-top)); left: 50%;
|
||||
transform: translateX(-50%); gap: 10px; white-space: nowrap;
|
||||
}
|
||||
#match .mode-label { background: transparent; color: #59665d; padding: 0; font-size: 11px; font-weight: 600; }
|
||||
#match .turn-heading { font-size: 17px; letter-spacing: -.5px; }
|
||||
#match .timing {
|
||||
position: absolute; top: calc(54px + env(safe-area-inset-top)); left: 50%;
|
||||
transform: translateX(-50%); width: 220px; min-height: 37px; padding: 0 0 13px;
|
||||
z-index: 12; font-size: 11px; align-items: center; gap: 8px;
|
||||
}
|
||||
#match .time-label { font-size: 16px; white-space: nowrap; }
|
||||
#match .time-track { bottom: 0; height: 4px; border-radius: 0; background: #39493c20; }
|
||||
#match .time-track span { border-radius: 0; background: #a85039; }
|
||||
#match[data-locked=true] .time-track span { background: #557b85; }
|
||||
#match .timing[data-urgent=true] .time-track span { background: #c04128; }
|
||||
#match .match-menu {
|
||||
pointer-events: auto;
|
||||
position: absolute; top: calc(25px + env(safe-area-inset-top));
|
||||
right: max(var(--edge), env(safe-area-inset-right));
|
||||
}
|
||||
#match .match-menu summary {
|
||||
display: grid; place-items: center; list-style: none; cursor: pointer;
|
||||
min-width: 52px; min-height: 44px; padding: 8px 12px;
|
||||
border: 1px solid #52615655; border-radius: 3px; background: #f4f0e3;
|
||||
font-size: 12px; font-weight: 700;
|
||||
}
|
||||
#match .match-menu summary::-webkit-details-marker { display: none; }
|
||||
#match .match-menu summary:focus-visible { outline: 3px solid #266acc; outline-offset: 4px; }
|
||||
#match .match-menu[open] summary { background: #303c38; color: #fff9e9; }
|
||||
#match .match-menu-items {
|
||||
position: absolute; top: calc(100% + 8px); right: 0; width: 132px;
|
||||
padding: 6px; background: #f4f0e3; border: 1px solid #697568;
|
||||
box-shadow: 3px 4px 0 #303c3820;
|
||||
}
|
||||
#match .match-menu-items button {
|
||||
display: block; width: 100%; min-height: 44px; border: 0; border-radius: 0;
|
||||
color: #303c38; text-align: left;
|
||||
}
|
||||
#match .duel-cue, #match[data-phase=countdown] .duel-cue { top: 43%; }
|
||||
#match:not([data-phase=countdown]) .versus { display: none; }
|
||||
#match[data-phase=countdown] .countdown-number { border-radius: 3px; }
|
||||
#match .clash-label { top: 30%; }
|
||||
#match .player-controls {
|
||||
position: relative; grid-area: 2 / 1; justify-self: start; z-index: 12;
|
||||
left: var(--self-position); translate: -50% 0;
|
||||
width: min(640px, calc(50% - 24px));
|
||||
margin-bottom: max(20px, env(safe-area-inset-bottom));
|
||||
}
|
||||
#match .action-buttons {
|
||||
position: relative; width: 100%;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 8px;
|
||||
margin: 0; padding: 8px;
|
||||
border: 1px solid #3f493e; border-radius: 4px; background: #303a33;
|
||||
box-shadow: 0 4px 0 #202a2580;
|
||||
}
|
||||
#match .action-buttons button {
|
||||
display: flex; flex-direction: column; grid-column: auto; gap: 5px;
|
||||
min-height: 86px; padding: 10px 4px; text-align: center;
|
||||
border: 1px solid #dfdac8; border-bottom: 3px solid #b0ab95; border-radius: 2px;
|
||||
background: #f5f0de; color: #303a33; box-shadow: none;
|
||||
}
|
||||
#match .action-buttons .action-symbol {
|
||||
grid-row: span 2; align-self: center; width: 28px; height: 28px;
|
||||
}
|
||||
#match .action-buttons strong { font-size: 13px; white-space: nowrap; }
|
||||
#match .action-buttons small { color: #58604f; font-size: 10px; }
|
||||
#match .action-buttons button:hover:not(:disabled) { background: #fff9e9; border-color: var(--action-color); transform: translateY(-2px); }
|
||||
#match .action-buttons button:active:not(:disabled) { transform: translateY(1px); }
|
||||
#match .action-buttons button:disabled { background: #b9bdb0; border-color: #a1a99b; color: #485146; }
|
||||
#match .action-buttons button[data-unavailable=true] { background: #555e53; border-color: #737d6c; color: #dde0d5; }
|
||||
#match .action-buttons button[data-unavailable=true] small { color: #dde0d5; }
|
||||
#match .action-buttons button.chosen { background: #edd5a5; border-color: #c39244; color: #453a26; box-shadow: inset 0 0 0 1px #c39244; }
|
||||
#match .chosen-mark { width: 20px; height: 20px; top: -6px; right: -4px; padding: 3px; border-width: 1px; }
|
||||
#match .match-result {
|
||||
position: relative; grid-area: 2 / 1; justify-self: center; z-index: 12;
|
||||
width: min(480px, calc(100% - 32px));
|
||||
max-height: calc(100dvh - 150px - env(safe-area-inset-top) - env(safe-area-inset-bottom));
|
||||
overflow-y: auto; margin: 0 0 max(20px, env(safe-area-inset-bottom)); padding: 8px 12px;
|
||||
border: 0; border-radius: 0; box-shadow: none; background: transparent;
|
||||
}
|
||||
#match .result-stats { margin: 0; color: #414c3c; }
|
||||
#match .match-result .split { margin-top: 12px; }
|
||||
#match .match-result button { border-radius: 3px; }
|
||||
#match .result-banner {
|
||||
position: absolute; z-index: 10; top: calc(90px + env(safe-area-inset-top));
|
||||
left: 50%; translate: -50% 0; width: calc(100% - 32px); text-align: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
#match .result-banner h2 {
|
||||
margin: 0; font-size: clamp(80px, 11vw, 164px); font-weight: 950; font-style: italic;
|
||||
line-height: 1; letter-spacing: -.065em; color: #ffd359;
|
||||
-webkit-text-stroke: 2px #755120; paint-order: stroke fill;
|
||||
text-shadow: 0 5px 0 #72501c, 0 10px 20px #543d2133;
|
||||
}
|
||||
#match .result-banner .finish-reason { margin: 14px 0 0; font-size: 13px; color: #4d5547; }
|
||||
#match[data-outcome=loss] .result-banner h2 { color: #d78063; -webkit-text-stroke-color: #703d32; text-shadow: 0 5px 0 #703d32; }
|
||||
#match:is([data-outcome=draw], [data-outcome=aborted]) .result-banner h2 { color: #e9eadc; -webkit-text-stroke-color: #50635d; text-shadow: 0 5px 0 #50635d; }
|
||||
body:is([data-screen=match], [data-screen=result]) #notice:not(:empty) {
|
||||
position: fixed; z-index: 18; top: calc(108px + env(safe-area-inset-top));
|
||||
left: 50%; transform: translateX(-50%); width: max-content; max-width: calc(100% - 32px);
|
||||
margin: 0; padding: 10px 14px; background: #fff6e4; border: 1px solid #a85039;
|
||||
}
|
||||
body:is([data-screen=match], [data-screen=result]) #reconnect {
|
||||
position: fixed; z-index: 18; top: calc(158px + env(safe-area-inset-top)); right: 16px;
|
||||
min-height: 44px; margin: 0; border-radius: 3px;
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
#match {
|
||||
--edge: 16px; --stage-bottom: 16px;
|
||||
--self-position: 25.09%;
|
||||
--stage-top: calc(100px + env(safe-area-inset-top));
|
||||
--fighter-size: min(60vw, calc((100cqh - 38px) * .866667), 360px);
|
||||
}
|
||||
#match[data-self="1"] { --self-position: 74.91%; }
|
||||
#match[data-phase=finished] { --stage-top: calc(210px + env(safe-area-inset-top)); }
|
||||
#match .arena { padding-inline: 3vw; }
|
||||
#match .fighter { width: 47%; }
|
||||
#match .match-title { top: calc(24px + env(safe-area-inset-top)); left: var(--edge); transform: none; }
|
||||
#match .turn-heading { font-size: 16px; }
|
||||
#match .match-menu { top: calc(12px + env(safe-area-inset-top)); }
|
||||
#match .energy { gap: 3px; }
|
||||
#match .energy-count { font-size: 25px; }
|
||||
#match .energy > svg { width: 18px; height: 18px; }
|
||||
#match .timing { top: calc(65px + env(safe-area-inset-top)); width: 160px; min-height: 30px; flex-direction: row; gap: 3px; padding-bottom: 9px; }
|
||||
#match .selection-status { font-size: 10px; }
|
||||
#match .time-label { font-size: 14px; }
|
||||
#match .player-controls { width: calc(50% - 16px); margin-bottom: max(12px, env(safe-area-inset-bottom)); }
|
||||
#match .action-buttons { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 5px; padding: 6px; }
|
||||
#match .action-buttons button {
|
||||
grid-column: auto; display: flex; flex-direction: column; gap: 4px;
|
||||
min-height: 56px; padding: 8px 2px; text-align: center;
|
||||
}
|
||||
#match .action-buttons .action-symbol { display: none; }
|
||||
#match .action-buttons button:nth-child(n+4) {
|
||||
grid-column: auto; display: flex; flex-direction: column;
|
||||
min-height: 56px; text-align: center; padding: 8px 2px;
|
||||
}
|
||||
#match .action-buttons button:last-child { grid-column: 1 / -1; }
|
||||
#match .action-buttons strong { font-size: 11px; }
|
||||
#match .action-buttons small { font-size: 9px; white-space: normal; }
|
||||
#match .fighter-caption { font-size: 10px; }
|
||||
#match .clash-label { top: 32%; font-size: 18px; }
|
||||
#match .match-result { margin-bottom: max(12px, env(safe-area-inset-bottom)); padding: 8px; }
|
||||
#match .result-banner { top: calc(82px + env(safe-area-inset-top)); }
|
||||
#match .result-banner h2 { font-size: clamp(60px, 18vw, 100px); }
|
||||
#match .match-result .split button { min-height: 44px; font-size: 13px; }
|
||||
}
|
||||
@media (max-height: 500px) and (min-width: 521px) {
|
||||
#match {
|
||||
--edge: max(16px, env(safe-area-inset-left)); --stage-bottom: 8px;
|
||||
--stage-top: calc(78px + env(safe-area-inset-top));
|
||||
--self-position: 28.34%;
|
||||
--fighter-size: min(26vw, calc((100cqh - 38px) * .866667), 260px);
|
||||
}
|
||||
#match[data-self="1"] { --self-position: 71.66%; }
|
||||
#match[data-phase=finished] { --stage-top: calc(96px + env(safe-area-inset-top)); }
|
||||
#match .arena { padding-inline: 12vw; }
|
||||
#match .match-title { top: calc(12px + env(safe-area-inset-top)); left: 50%; transform: translateX(-50%); }
|
||||
#match .match-menu { top: calc(10px + env(safe-area-inset-top)); right: max(12px, env(safe-area-inset-right)); }
|
||||
#match .timing { top: calc(38px + env(safe-area-inset-top)); width: 120px; min-height: 33px; flex-direction: row; padding-bottom: 8px; }
|
||||
#match .player-controls { width: calc(50% - 24px); margin-bottom: max(8px, env(safe-area-inset-bottom)); }
|
||||
#match .energy { padding-bottom: 4px; }
|
||||
#match .energy-count { font-size: 18px; }
|
||||
#match .action-buttons { grid-template-columns: repeat(5, minmax(0, 1fr)); padding: 5px; gap: 4px; }
|
||||
#match .action-buttons button, #match .action-buttons button:nth-child(n+4) {
|
||||
display: flex; grid-column: auto; flex-direction: column; min-height: 56px; padding: 7px 2px; gap: 4px; text-align: center;
|
||||
}
|
||||
#match .action-buttons .action-symbol, #match .action-buttons button:nth-child(n+4) .action-symbol { display: none; }
|
||||
#match .action-buttons strong { font-size: 10px; white-space: normal; overflow-wrap: anywhere; }
|
||||
#match .action-buttons small { font-size: 9px; white-space: normal; }
|
||||
#match .duel-cue, #match[data-phase=countdown] .duel-cue { top: 44%; }
|
||||
#match .clash-label { top: 25%; font-size: 17px; }
|
||||
#match .match-result {
|
||||
display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: center; gap: 4px 12px;
|
||||
width: min(650px, calc(100% - 32px)); margin-bottom: max(8px, env(safe-area-inset-bottom));
|
||||
max-height: calc(100dvh - 160px - env(safe-area-inset-top)); padding: 6px 10px;
|
||||
}
|
||||
#match .result-stats { margin: 0; font-size: 11px; }
|
||||
#match .match-result .split { grid-area: 1 / 2; margin: 0; gap: 8px; }
|
||||
#match .match-result .split button { min-height: 44px; padding: 8px; font-size: 12px; }
|
||||
#match :is(.save-status, .rematch-status) { grid-column: 1 / -1; margin: 0; font-size: 10px; line-height: 1.3; }
|
||||
#match .result-banner { top: calc(56px + env(safe-area-inset-top)); }
|
||||
#match .result-banner h2 { font-size: 48px; }
|
||||
#match .result-banner .finish-reason { margin-top: 8px; font-size: 11px; }
|
||||
}
|
||||
@media (max-height: 500px) and (min-width: 521px) and (max-width: 760px) {
|
||||
#match { --self-position: 29.86%; }
|
||||
#match[data-self="1"] { --self-position: 70.14%; }
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import type { Socket } from 'socket.io-client';
|
||||
import { RULES_VERSION } from '../../constants/gameplay.js';
|
||||
import { CONNECT_TIMEOUT_MS, MAX_PEER_MESSAGE_BYTES, PROTOCOL_VERSION } from '../../constants/webrtc.js';
|
||||
import type { PeerMessage, SignalingPacket } from '../shared/p2p-types.js';
|
||||
|
||||
export type PeerState = 'connecting' | 'connected' | 'disconnected' | 'failed' | 'closed';
|
||||
export interface PeerConfig {
|
||||
matchId: string; roomId: string; slot: 0 | 1; epoch?: number; iceServers: RTCIceServer[];
|
||||
createSignaling(): Socket; createConnection?: (config: RTCConfiguration) => RTCPeerConnection;
|
||||
refreshIce?: () => Promise<RTCIceServer[]>;
|
||||
}
|
||||
export function createPeer(config: PeerConfig) {
|
||||
let pc: RTCPeerConnection | undefined, channel: RTCDataChannel | undefined, signal: Socket | undefined;
|
||||
let epoch = config.epoch ?? 0, disposed = false, ready = false, remoteReady = false, offering = false;
|
||||
let connectionPromise: Promise<void> | undefined, yes: (() => void) | undefined, no: ((e: Error) => void) | undefined;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined, signalQueue = Promise.resolve();
|
||||
let candidates: RTCIceCandidateInit[] = [];
|
||||
let generation = 0;
|
||||
const messages = new Set<(message: PeerMessage) => void>(), states = new Set<(state: PeerState) => void>();
|
||||
const emitState = (state: PeerState) => { for (const handler of states) handler(state); };
|
||||
const stopSignaling = () => { signal?.removeAllListeners(); signal?.disconnect(); signal = undefined; };
|
||||
function discardConnection() {
|
||||
const oldChannel = channel, oldPc = pc; channel = undefined; pc = undefined;
|
||||
if (oldChannel) { oldChannel.onopen = oldChannel.onclose = oldChannel.onerror = oldChannel.onmessage = null; oldChannel.close(); }
|
||||
if (oldPc) { oldPc.ondatachannel = oldPc.onicecandidate = oldPc.onconnectionstatechange = null; oldPc.close(); }
|
||||
}
|
||||
function failure(error: unknown, attempt = generation) {
|
||||
// In-flight ICE acknowledgements can arrive after both sides close signaling.
|
||||
if (disposed || attempt !== generation || (ready && channel?.readyState === 'open')) return;
|
||||
clearTimeout(timer); stopSignaling(); no?.(error instanceof Error ? error : new Error('PEER_FAILED'));
|
||||
yes = undefined; no = undefined; connectionPromise = undefined; ready = false; emitState('failed');
|
||||
}
|
||||
function complete() {
|
||||
if (!remoteReady || channel?.readyState !== 'open' || ready) return;
|
||||
ready = true; clearTimeout(timer); stopSignaling();
|
||||
yes?.(); yes = undefined; no = undefined; emitState('connected');
|
||||
}
|
||||
function announceReady() {
|
||||
if (channel?.readyState === 'open') {
|
||||
channel.send(JSON.stringify({ type: 'transport:ready', matchId: config.matchId, epoch, slot: config.slot, protocolVersion: PROTOCOL_VERSION, rulesVersion: RULES_VERSION }));
|
||||
complete();
|
||||
}
|
||||
}
|
||||
function installChannel(next: RTCDataChannel) {
|
||||
channel = next;
|
||||
channel.onopen = announceReady;
|
||||
channel.onclose = () => { if (!disposed) { ready = false; emitState('disconnected'); } };
|
||||
channel.onerror = () => { if (!disposed) emitState('disconnected'); };
|
||||
channel.onmessage = event => {
|
||||
if (disposed || typeof event.data !== 'string' || new TextEncoder().encode(event.data).byteLength > MAX_PEER_MESSAGE_BYTES) return;
|
||||
let message: any;
|
||||
try { message = JSON.parse(event.data); } catch { return; }
|
||||
if (!message || message.matchId !== config.matchId || message.protocolVersion !== PROTOCOL_VERSION || message.rulesVersion !== RULES_VERSION) return;
|
||||
if (message.type === 'transport:ready') {
|
||||
if (message.epoch !== epoch || message.slot === config.slot) return;
|
||||
remoteReady = true; complete(); return;
|
||||
}
|
||||
if (!ready || message.connectionEpoch !== epoch) return;
|
||||
for (const handler of messages) handler(message as PeerMessage);
|
||||
};
|
||||
if (channel.readyState === 'open') announceReady();
|
||||
}
|
||||
async function sendSignal(type: SignalingPacket['type'], payload: SignalingPacket['payload'], attempt = generation) {
|
||||
if (disposed || attempt !== generation) return;
|
||||
if (!signal?.connected) throw new Error('SIGNAL_DISCONNECTED');
|
||||
const ack = await signal.timeout(5000).emitWithAck('signal', { type, payload, matchId: config.matchId, epoch } satisfies SignalingPacket);
|
||||
if (disposed || attempt !== generation) return;
|
||||
if (!ack?.ok) throw new Error(ack?.code ?? 'SIGNAL_FAILED');
|
||||
}
|
||||
function installConnection() {
|
||||
const current = pc = (config.createConnection ?? (settings => new RTCPeerConnection(settings)))({ iceServers: config.iceServers });
|
||||
current.ondatachannel = event => installChannel(event.channel);
|
||||
current.onicecandidate = event => {
|
||||
const attempt = generation;
|
||||
if (current === pc && event.candidate && signal?.connected) void sendSignal('ice', event.candidate.toJSON(), attempt).catch(error => failure(error, attempt));
|
||||
};
|
||||
current.onconnectionstatechange = () => {
|
||||
if (disposed || current !== pc) return;
|
||||
if (current.connectionState === 'failed' || current.connectionState === 'disconnected') { ready = false; emitState('disconnected'); }
|
||||
else if (current.connectionState === 'connected' && !ready) announceReady();
|
||||
};
|
||||
if (config.slot === 0) installChannel(current.createDataChannel('game', { ordered: true }));
|
||||
}
|
||||
async function received(packet: SignalingPacket, attempt: number) {
|
||||
if (disposed || attempt !== generation || !pc || packet.matchId !== config.matchId || packet.epoch !== epoch) return;
|
||||
const current = pc;
|
||||
if (packet.type === 'ice') {
|
||||
if (!current.remoteDescription) candidates.push(packet.payload as RTCIceCandidateInit);
|
||||
else await current.addIceCandidate(packet.payload as RTCIceCandidateInit);
|
||||
return;
|
||||
}
|
||||
if ((packet.type === 'offer' && config.slot !== 1) || (packet.type === 'answer' && config.slot !== 0)) return;
|
||||
await current.setRemoteDescription(packet.payload as RTCSessionDescriptionInit);
|
||||
if (disposed || attempt !== generation) return;
|
||||
for (const candidate of candidates.splice(0)) await current.addIceCandidate(candidate);
|
||||
if (packet.type === 'offer') {
|
||||
const answer = await current.createAnswer(); if (disposed || attempt !== generation) return;
|
||||
await current.setLocalDescription(answer);
|
||||
await sendSignal('answer', current.localDescription!, attempt);
|
||||
}
|
||||
if (attempt === generation && channel?.readyState === 'open') announceReady();
|
||||
}
|
||||
function connect(): Promise<void> {
|
||||
if (disposed) return Promise.reject(new Error('PEER_CLOSED'));
|
||||
if (ready) return Promise.resolve();
|
||||
if (connectionPromise) return connectionPromise;
|
||||
const attempt = ++generation;
|
||||
remoteReady = false; offering = false;
|
||||
connectionPromise = new Promise<void>((resolve, reject) => { yes = resolve; no = reject; });
|
||||
const currentPromise = connectionPromise;
|
||||
timer = setTimeout(() => failure(new Error('PEER_TIMEOUT'), attempt), CONNECT_TIMEOUT_MS);
|
||||
if (!pc || pc.connectionState === 'closed' || channel?.readyState === 'closed') { discardConnection(); installConnection(); }
|
||||
signal = config.createSignaling();
|
||||
signalQueue = Promise.resolve();
|
||||
signal.on('signal', (packet: SignalingPacket) => { signalQueue = signalQueue.then(() => received(packet, attempt)).catch(error => failure(error, attempt)); });
|
||||
signal.on('signal:peers', ({ count }: { count: number }) => {
|
||||
if (attempt !== generation || count < 2 || config.slot !== 0 || offering || !pc) return;
|
||||
offering = true;
|
||||
const current = pc;
|
||||
void (async () => {
|
||||
const offer = await current.createOffer(); if (disposed || attempt !== generation) return;
|
||||
await current.setLocalDescription(offer); await sendSignal('offer', current.localDescription!, attempt);
|
||||
})().catch(error => failure(error, attempt));
|
||||
});
|
||||
signal.on('connect_error', error => failure(error, attempt));
|
||||
signal.on('disconnect', reason => { if (!ready && reason === 'io server disconnect') failure(new Error('SIGNAL_CLOSED'), attempt); });
|
||||
signal.connect(); emitState('connecting'); return currentPromise;
|
||||
}
|
||||
return {
|
||||
connect,
|
||||
get epoch() { return epoch; },
|
||||
get connected() { return ready && channel?.readyState === 'open'; },
|
||||
send(message: PeerMessage) {
|
||||
if (!ready || channel?.readyState !== 'open') throw new Error('PEER_NOT_CONNECTED');
|
||||
const body = JSON.stringify(message);
|
||||
if (new TextEncoder().encode(body).byteLength > MAX_PEER_MESSAGE_BYTES) throw new Error('MESSAGE_TOO_LARGE');
|
||||
channel.send(body);
|
||||
},
|
||||
onMessage(handler: (message: PeerMessage) => void) { messages.add(handler); return () => { messages.delete(handler); }; },
|
||||
onState(handler: (state: PeerState) => void) { states.add(handler); return () => { states.delete(handler); }; },
|
||||
async restart(nextEpoch = epoch + 1) {
|
||||
generation++;
|
||||
stopSignaling(); clearTimeout(timer); no?.(new Error('PEER_RESTARTED'));
|
||||
ready = false; remoteReady = false; yes = undefined; no = undefined; connectionPromise = undefined;
|
||||
epoch = nextEpoch; candidates = [];
|
||||
if (channel?.readyState === 'closed' || channel?.readyState === 'closing' || pc?.connectionState === 'closed') discardConnection();
|
||||
if (config.refreshIce) {
|
||||
config.iceServers = await config.refreshIce();
|
||||
if (disposed) throw new Error('PEER_CLOSED');
|
||||
if (pc) {
|
||||
try { pc.setConfiguration({ ...pc.getConfiguration(), iceServers: config.iceServers }); }
|
||||
catch { discardConnection(); }
|
||||
}
|
||||
}
|
||||
pc?.restartIce(); return connect();
|
||||
},
|
||||
stats: async () => pc?.getStats(),
|
||||
close() {
|
||||
disposed = true; generation++; ready = false; clearTimeout(timer); stopSignaling();
|
||||
no?.(new Error('PEER_CLOSED')); no = undefined; yes = undefined;
|
||||
channel?.close(); pc?.close(); emitState('closed'); messages.clear(); states.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
export type PeerConnection = ReturnType<typeof createPeer>;
|
||||
@@ -0,0 +1,93 @@
|
||||
Copyright 2015 The Black Han Sans Project Authors (https://github.com/zesstype/Black-Han-Sans)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://openfontlicense.org
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -0,0 +1,56 @@
|
||||
import { PENDING_RESULT_KEY, RESULT_RETRY_DELAYS_MS } from '../../constants/recording.js';
|
||||
import type { RecordAck, ResultSubmission } from '../shared/p2p-types.js';
|
||||
|
||||
export type SessionStorage = Pick<Storage, 'getItem' | 'setItem' | 'removeItem'>;
|
||||
interface ReporterApi { gameSessionId: string; record(data: ResultSubmission): Promise<RecordAck> }
|
||||
interface RetryClock { setTimeout(fn: () => void, ms: number): any; clearTimeout(id: any): void }
|
||||
const clock: RetryClock = { setTimeout: (fn, ms) => setTimeout(fn, ms), clearTimeout: id => clearTimeout(id) };
|
||||
export function createResultReporter(api: ReporterApi, storage: SessionStorage, timers: RetryClock = clock) {
|
||||
let pending: ResultSubmission | undefined, promise: Promise<RecordAck> | undefined;
|
||||
let resolve: ((ack: RecordAck) => void) | undefined, reject: ((error: Error) => void) | undefined;
|
||||
let timer: any, attempts = 0, running = false, destroyed = false;
|
||||
async function attempt() {
|
||||
if (!pending || running || destroyed || !resolve) return;
|
||||
timers.clearTimeout(timer); timer = undefined; running = true;
|
||||
try {
|
||||
const response = await api.record(structuredClone(pending));
|
||||
if (destroyed) return;
|
||||
storage.removeItem(PENDING_RESULT_KEY); pending = undefined;
|
||||
resolve(response); resolve = undefined; reject = undefined; promise = undefined;
|
||||
} catch (error) {
|
||||
if (destroyed) return;
|
||||
const status = (error as { status?: number }).status;
|
||||
if (status && status >= 400 && status < 500 && status !== 408 && status !== 429) {
|
||||
reject?.(error as Error); resolve = undefined; reject = undefined; promise = undefined;
|
||||
} else {
|
||||
const delay = RESULT_RETRY_DELAYS_MS[Math.min(attempts++, RESULT_RETRY_DELAYS_MS.length - 1)];
|
||||
timer = timers.setTimeout(() => { void attempt(); }, delay);
|
||||
}
|
||||
} finally { running = false; }
|
||||
}
|
||||
const online = () => { void attempt(); };
|
||||
globalThis.addEventListener?.('online', online);
|
||||
function submit(result: ResultSubmission): Promise<RecordAck> {
|
||||
if (destroyed) return Promise.reject(new Error('REPORTER_CLOSED'));
|
||||
if (pending && JSON.stringify(pending) !== JSON.stringify(result)) return Promise.reject(new Error('RESULT_PENDING'));
|
||||
if (promise) return promise;
|
||||
pending = structuredClone(result); attempts = 0;
|
||||
storage.setItem(PENDING_RESULT_KEY, JSON.stringify({ gameSessionId: api.gameSessionId, result: pending }));
|
||||
promise = new Promise<RecordAck>((yes, no) => { resolve = yes; reject = no; });
|
||||
const current = promise; void attempt(); return current;
|
||||
}
|
||||
return {
|
||||
submit,
|
||||
async resumePending(): Promise<RecordAck | undefined> {
|
||||
const raw = storage.getItem(PENDING_RESULT_KEY); if (!raw) return;
|
||||
const saved = JSON.parse(raw) as { gameSessionId: string; result: ResultSubmission };
|
||||
if (saved.gameSessionId !== api.gameSessionId) throw new Error('SESSION_MISMATCH');
|
||||
return submit(saved.result);
|
||||
},
|
||||
destroy() {
|
||||
destroyed = true; timers.clearTimeout(timer); globalThis.removeEventListener?.('online', online);
|
||||
reject?.(new Error('REPORTER_CLOSED')); reject = undefined; resolve = undefined;
|
||||
},
|
||||
};
|
||||
}
|
||||
export type ResultReporter = ReturnType<typeof createResultReporter>;
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,165 @@
|
||||
/* Import after game.css, match.css and home.css. */
|
||||
@font-face {
|
||||
font-family: 'Black Han Sans';
|
||||
src: url('./fonts/BlackHanSans-Regular.ttf') format('truetype');
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
body.player-app {
|
||||
--ink: #242526;
|
||||
--muted: #666;
|
||||
--line: #d6d6d6;
|
||||
--ui-orange: #ff912b;
|
||||
--ui-display: 'Black Han Sans', 'Malgun Gothic', sans-serif;
|
||||
--ui-body: 'Segoe UI', 'Apple SD Gothic Neo', 'Malgun Gothic', sans-serif;
|
||||
background: #fff;
|
||||
color: var(--ink);
|
||||
font-family: var(--ui-body);
|
||||
}
|
||||
|
||||
body.player-app :is(h1, h2, button, summary),
|
||||
body.player-app #match .action-buttons strong {
|
||||
font-family: var(--ui-display);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
body.player-app button,
|
||||
body.player-app #match button,
|
||||
body.player-app #match .match-menu summary {
|
||||
border: 2px solid var(--ink);
|
||||
border-radius: 0;
|
||||
background: var(--ink);
|
||||
color: #fff;
|
||||
box-shadow: 4px 4px 0 #24252640;
|
||||
transition: transform .12s, background .12s, box-shadow .12s;
|
||||
}
|
||||
|
||||
body.player-app button.primary,
|
||||
body.player-app #match button.primary,
|
||||
body.player-app #match .action-buttons button[data-action=charge] {
|
||||
border-radius: 0;
|
||||
background: var(--ui-orange);
|
||||
color: var(--ink);
|
||||
box-shadow: 4px 4px 0 var(--ink);
|
||||
}
|
||||
|
||||
body.player-app button:hover:not(:disabled),
|
||||
body.player-app #match button:hover:not(:disabled),
|
||||
body.player-app #match .match-menu summary:hover {
|
||||
background: #ffbd43;
|
||||
border-color: var(--ink);
|
||||
color: var(--ink);
|
||||
transform: translate(-2px, -2px);
|
||||
}
|
||||
|
||||
body.player-app button:active:not(:disabled),
|
||||
body.player-app #match button:active:not(:disabled),
|
||||
body.player-app #match .match-menu summary:active {
|
||||
border-width: 2px;
|
||||
box-shadow: none;
|
||||
transform: translate(3px, 3px);
|
||||
}
|
||||
|
||||
body.player-app button:disabled,
|
||||
body.player-app #match button:disabled,
|
||||
body.player-app #match .action-buttons button[data-unavailable=true] {
|
||||
background: #e4e4e4;
|
||||
border-color: #aaa;
|
||||
color: #666;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
body.player-app #match .action-buttons button.chosen {
|
||||
background: var(--ui-orange);
|
||||
border-color: var(--ink);
|
||||
color: var(--ink);
|
||||
box-shadow: inset 0 0 0 2px #fff, 3px 3px 0 var(--ink);
|
||||
}
|
||||
|
||||
body.player-app :is(button, summary, input):focus-visible {
|
||||
outline: 3px solid #1967b8;
|
||||
outline-offset: 4px;
|
||||
}
|
||||
|
||||
body.player-app input { background: #fff; border-color: #777; border-radius: 0; color: var(--ink); }
|
||||
body.player-app #room-code { width: 0; min-width: 0; font-size: clamp(16px, 3vw, 22px); letter-spacing: .12em; }
|
||||
body.player-app .join-form button[type=submit] { flex-shrink: 0; }
|
||||
|
||||
body.player-app .waiting { padding-block: 32px; }
|
||||
body.player-app .waiting h1 { flex-wrap: wrap; }
|
||||
body.player-app #waiting-fighter { background: #ffb44a; }
|
||||
body.player-app .waiting-dots i { background: var(--ink); }
|
||||
body.player-app .waiting .room-code { background: #fff2df; border: 2px dashed var(--ink); border-radius: 0; font-size: clamp(28px, 8vw, 44px); letter-spacing: .15em; }
|
||||
body.player-app .waiting .split { flex-wrap: wrap; }
|
||||
body.player-app .waiting .split button { padding-inline: 12px; }
|
||||
body.player-app #waiting-leave { margin-top: 12px; }
|
||||
|
||||
body.player-app #match .action-buttons { background: transparent; border: 0; border-radius: 0; box-shadow: none; }
|
||||
body.player-app #match .action-buttons button { box-shadow: 3px 3px 0 #24252640; }
|
||||
body.player-app #match .action-buttons :is(button[data-action=charge], button.chosen) { box-shadow: 3px 3px 0 var(--ink); }
|
||||
body.player-app #match .action-buttons button:disabled:not(.chosen) { box-shadow: none; }
|
||||
body.player-app #match .action-buttons button[data-action=charge]:disabled:not(.chosen) { background: #e4e4e4; color: #666; }
|
||||
body.player-app #match .action-buttons small { color: inherit; font-family: var(--ui-body); font-weight: 400; }
|
||||
body.player-app #match .action-buttons .action-symbol { color: inherit; opacity: 1; filter: none; }
|
||||
body.player-app #match .energy > svg { color: var(--ink); }
|
||||
body.player-app #match .chosen-mark { background: var(--ink); border-color: #fff; color: #fff; border-radius: 0; }
|
||||
body.player-app #match .match-menu-items { background: #fff; border: 2px solid var(--ink); border-radius: 0; box-shadow: 4px 4px 0 #24252640; padding: 8px; }
|
||||
body.player-app #match .match-menu-items button + button { margin-top: 8px; }
|
||||
|
||||
/* Keep the footer in layout during combat so it never covers action buttons. */
|
||||
body.player-app .app > footer.app-footer {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
justify-content: flex-end;
|
||||
gap: 0;
|
||||
padding: 8px max(24px, env(safe-area-inset-right)) max(8px, env(safe-area-inset-bottom)) max(16px, env(safe-area-inset-left));
|
||||
background: #fff;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
body.player-app .app-footer nav { display: flex; align-items: center; gap: 4px; }
|
||||
body.player-app .app-footer nav > span { font-size: 12px; }
|
||||
body.player-app .app-footer #connection { min-height: 16px; font-size: 10px; color: var(--muted); }
|
||||
body.player-app .app-footer button.quiet {
|
||||
min-height: 44px;
|
||||
padding: 8px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #454545;
|
||||
box-shadow: none;
|
||||
font-size: 12px;
|
||||
transform: none;
|
||||
}
|
||||
body.player-app .app-footer button.quiet:hover { background: #fff0df; color: var(--ink); }
|
||||
body.player-app .app-footer button.quiet:active { background: #ffe2bd; transform: none; }
|
||||
body.player-app:is([data-screen=home], [data-screen=join]) .app-footer { position: absolute; right: 0; bottom: 0; background: transparent; }
|
||||
body.player-app:is([data-screen=match], [data-screen=result]) .app > footer.app-footer { display: flex; }
|
||||
|
||||
body.player-app dialog:not(.finish-cutscene) {
|
||||
padding: 24px;
|
||||
border: 2px solid var(--ink);
|
||||
border-radius: 0;
|
||||
background: #fff;
|
||||
color: var(--ink);
|
||||
box-shadow: 6px 6px 0 #24252680;
|
||||
}
|
||||
body.player-app dialog:not(.finish-cutscene)::backdrop { background: #24252699; }
|
||||
body.player-app dialog:not(.finish-cutscene) p { color: #4d4d4d; }
|
||||
body.player-app .dialog-top button { flex-shrink: 0; }
|
||||
body.player-app #contact-content { overflow-wrap: anywhere; }
|
||||
|
||||
@media (max-width: 760px) {
|
||||
body.player-app:is([data-screen=home], [data-screen=join]) .app > footer.app-footer { position: static; margin-top: auto; }
|
||||
body.player-app[data-screen=waiting] .app { padding-inline: max(20px, env(safe-area-inset-left)); }
|
||||
body.player-app[data-screen=waiting] .app > footer.app-footer { padding-inline: 0; }
|
||||
body.player-app dialog:not(.finish-cutscene) { padding: 18px; }
|
||||
body.player-app .dialog-top h2 { font-size: 19px; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
body.player-app button, body.player-app #match button, body.player-app #match .match-menu summary { transition: none; }
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { createHash, timingSafeEqual } from 'node:crypto';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { Analytics } from './analytics.js';
|
||||
import { getAnalyticsReport, validateFilters } from './analytics-report.js';
|
||||
import type { StatsStore } from './stats-store.js';
|
||||
import type { Clock } from './clock.js';
|
||||
import { RETENTION_MS } from '../../constants/analytics.js';
|
||||
export function registerAdmin(app: FastifyInstance, store: StatsStore, analytics: Analytics, clock: Clock, credentials: { user?: string; password?: string }, directory: string) {
|
||||
const digest = (value: string) => createHash('sha256').update(value).digest();
|
||||
const authorized = (header: string | undefined) => {
|
||||
if (!credentials.user || !credentials.password) return false;
|
||||
if (!header?.startsWith('Basic ') || header.length > 4096) return false;
|
||||
return timingSafeEqual(digest(Buffer.from(header.slice(6), 'base64').toString()), digest(`${credentials.user}:${credentials.password}`));
|
||||
};
|
||||
app.register(async admin => {
|
||||
admin.addHook('onRequest', async (request, reply) => {
|
||||
reply.header('Cache-Control', 'no-store');
|
||||
if (!credentials.user || !credentials.password) return reply.code(404).send({ error: '관리자 분석이 비활성화되었습니다.' });
|
||||
if (!authorized(request.headers.authorization)) return reply.header('WWW-Authenticate', 'Basic realm="analytics", charset="UTF-8"').code(401).send({ error: '관리자 인증이 필요합니다.' });
|
||||
});
|
||||
admin.get('/admin/analytics', (_request, reply) => reply.sendFile('admin/analytics/index.html', directory));
|
||||
admin.get('/api/admin/analytics/report', async (request, reply) => {
|
||||
let filters;
|
||||
try { filters = validateFilters(request.query as Record<string, unknown>, new Date(clock.now())); }
|
||||
catch { return reply.code(400).send({ error: '최근 90일의 시작·종료 시각과 필터를 확인하세요.' }); }
|
||||
const report = await getAnalyticsReport(store.db, filters, new Date(clock.now()));
|
||||
const collection = analytics.status();
|
||||
if (collection.incomplete) report.integrityIssues.push('이벤트 수집 누락 또는 저장 대기');
|
||||
return { ...report, live: analytics.live(), collection };
|
||||
});
|
||||
admin.get('/api/admin/analytics/live', async () => ({ live: analytics.live(), collection: analytics.status() }));
|
||||
admin.get('/api/admin/analytics/users/:id', async (request, reply) => {
|
||||
const id = (request.params as { id: string }).id, q = request.query as { page?: string; pageSize?: string };
|
||||
const page = Number(q.page ?? 1), pageSize = Number(q.pageSize ?? 20);
|
||||
if (!/^[a-zA-Z0-9_-]{1,80}$/.test(id) || !Number.isInteger(page) || page < 1 || page > 10000 || !Number.isInteger(pageSize) || pageSize < 1 || pageSize > 50) return reply.code(400).send({ error: '조회 범위를 확인하세요.' });
|
||||
const player = await store.db.collection('players').findOne({ _id: id as any }, { projection: { _id: 1, firstVisitAt: 1 } });
|
||||
if (!player) return reply.code(404).send({ error: '사용자를 찾을 수 없습니다.' });
|
||||
const cutoff = new Date(clock.now() - RETENTION_MS), skip = (page - 1) * pageSize;
|
||||
const [sessions, events, gameSessions] = await Promise.all([
|
||||
store.db.collection('analytics_sessions').find({ playerId: id, lastAt: { $gte: cutoff } }, { projection: { _id: 0 } }).sort({ startedAt: -1 }).skip(skip).limit(pageSize + 1).toArray(),
|
||||
store.db.collection('analytics_events').find({ playerId: id, occurredAt: { $gte: cutoff }, environment: 'production' }).sort({ occurredAt: -1 }).skip(skip).limit(pageSize + 1).toArray(),
|
||||
store.db.collection('game_sessions').find({ playerId: id, expiresAt: { $gt: new Date(clock.now()) }, environment: { $ne: 'test' } },
|
||||
{ projection: { _id: 1, stats: 1, createdAt: 1, updatedAt: 1, expiresAt: 1 } }).sort({ updatedAt: -1 }).skip(skip).limit(pageSize + 1).toArray(),
|
||||
]);
|
||||
return { playerId: id, firstVisitAt: player.firstVisitAt, lastVisitAt: sessions[0]?.lastAt,
|
||||
sessions: sessions.slice(0, pageSize), events: events.slice(0, pageSize), page, pageSize,
|
||||
gameSessions: gameSessions.slice(0, pageSize).map(({ _id, ...state }) => ({ gameSessionId: _id, ...state })),
|
||||
hasMore: [sessions, events, gameSessions].some(rows => rows.length > pageSize) };
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import type { Db } from 'mongodb';
|
||||
import { RETENTION_MS } from '../../constants/analytics.js';
|
||||
import type { AnalyticsEvent, AnalyticsFilters, Cohort, Ratio, VisitSession } from '../shared/analytics-types.js';
|
||||
import type { PlayerDocument } from './stats-store.js';
|
||||
export const kstDay = (date: Date) => new Date(+date + 9 * 3600000).toISOString().slice(0, 10);
|
||||
const dayMs = 86400000;
|
||||
const dayStart = (day: string) => +new Date(`${day}T00:00:00+09:00`);
|
||||
const unique = <T>(values: T[]) => new Set(values).size;
|
||||
export const ratio = (numerator: number, denominator: number): Ratio => ({ numerator, denominator, percent: denominator ? numerator / denominator * 100 : null });
|
||||
export function retention(first: { playerId: string; day: string }[], activity: { playerId: string; day: string }[], now: Date): Cohort[] {
|
||||
const groups = new Map<string, Set<string>>();
|
||||
for (const row of first) { if (!groups.has(row.day)) groups.set(row.day, new Set()); groups.get(row.day)!.add(row.playerId); }
|
||||
const active = new Set(activity.map(a => `${a.playerId}:${a.day}`));
|
||||
return [...groups.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([day, ids]) => {
|
||||
const rate = (offset: number) => {
|
||||
const target = dayStart(day) + offset * dayMs;
|
||||
if (target + dayMs > +now) return null;
|
||||
const targetDay = kstDay(new Date(target));
|
||||
return ratio([...ids].filter(id => active.has(`${id}:${targetDay}`)).length, ids.size);
|
||||
};
|
||||
return { day, size: ids.size, d1: rate(1), d7: rate(7) };
|
||||
});
|
||||
}
|
||||
export function validateFilters(query: Record<string, unknown>, now: Date): AnalyticsFilters {
|
||||
const end = query.end === undefined ? new Date(now) : new Date(String(query.end));
|
||||
const start = query.start === undefined ? new Date(+end - 7 * dayMs) : new Date(String(query.start));
|
||||
if (!Number.isFinite(+start) || !Number.isFinite(+end) || +start >= +end || +start < +now - RETENTION_MS || +end > +now + 1000) throw new Error('INVALID_RANGE');
|
||||
const allow: Record<string, string[]> = { mode: ['bot', 'friend'], audience: ['new', 'returning'], device: ['pc', 'mobile', 'tablet'], browser: ['Chrome', 'Edge', 'Firefox', 'Safari', 'Other'] };
|
||||
for (const [key, values] of Object.entries(allow)) if (query[key] && !values.includes(String(query[key]))) throw new Error('INVALID_FILTER');
|
||||
if (query.source && !/^[a-zA-Z0-9_.-]{1,80}$/.test(String(query.source))) throw new Error('INVALID_FILTER');
|
||||
return { start, end, ...Object.fromEntries(['mode', 'audience', 'device', 'browser', 'source'].filter(k => query[k]).map(k => [k, String(query[k])])) } as AnalyticsFilters;
|
||||
}
|
||||
export function median(values: number[]) {
|
||||
if (!values.length) return null;
|
||||
const sorted = [...values].sort((a, b) => a - b), i = Math.floor(sorted.length / 2);
|
||||
return sorted.length % 2 ? sorted[i] : (sorted[i - 1] + sorted[i]) / 2;
|
||||
}
|
||||
function distribution(values: number[], boundaries: number[]) {
|
||||
return [...boundaries, Infinity].map((upper, i) => ({ lower: i ? boundaries[i - 1] : 0, upper: Number.isFinite(upper) ? upper : null,
|
||||
count: values.filter(v => v >= (i ? boundaries[i - 1] : 0) && v < upper).length }));
|
||||
}
|
||||
export async function getAnalyticsReport(db: Db, filters: AnalyticsFilters, now: Date) {
|
||||
const cutoff = new Date(+now - RETENTION_MS), start = filters.start, end = filters.end;
|
||||
const allEvents = await db.collection<AnalyticsEvent>('analytics_events').aggregate<AnalyticsEvent>([
|
||||
{ $match: { occurredAt: { $gte: cutoff, $lt: now }, environment: 'production' } },
|
||||
{ $sort: { occurredAt: 1, _id: 1 } }, { $project: { expiresAt: 0 } },
|
||||
]).toArray();
|
||||
const players = await db.collection<PlayerDocument>('players').find({}, { projection: { _id: 1, firstVisitAt: 1 } }).toArray();
|
||||
const playerById = new Map(players.map(p => [p._id, p]));
|
||||
const sessions = await db.collection<VisitSession>('analytics_sessions').find({ lastAt: { $gte: cutoff } }, { projection: { _id: 0 } }).toArray();
|
||||
const sessionById = new Map(sessions.map(s => [s.sessionId, s]));
|
||||
const period = (date: Date) => +date >= +start && +date < +end;
|
||||
const qualifies = (playerId?: string, sessionId?: string) => {
|
||||
const s = sessionId ? sessionById.get(sessionId) : undefined;
|
||||
if (filters.device && s?.metadata.device !== filters.device) return false;
|
||||
if (filters.browser && !s?.metadata.browser.startsWith(filters.browser)) return false;
|
||||
if (filters.source && s?.metadata.source !== filters.source) return false;
|
||||
const first = playerId ? playerById.get(playerId)?.firstVisitAt : undefined;
|
||||
if (filters.audience && (!first || (filters.audience === 'new' ? !period(first) : +first >= +start))) return false;
|
||||
return true;
|
||||
};
|
||||
const events = allEvents.filter(e => period(e.occurredAt) && qualifies(e.playerId, e.sessionId));
|
||||
const clientGames = events.filter(e => e.source === 'client' && ['game_started', 'game_finished'].includes(e.eventType) && (!filters.mode || e.mode === filters.mode));
|
||||
const clientStarts = clientGames.filter(e => e.eventType === 'game_started');
|
||||
const clientResults = clientGames.filter(e => e.eventType === 'game_finished');
|
||||
const visits = events.filter(e => e.eventType === 'page_view');
|
||||
const visitors = new Set(visits.map(e => e.playerId!));
|
||||
const visitSessions = new Set(visits.map(e => e.sessionId!));
|
||||
const daily = new Map<string, { day: string; visitors: Set<string>; players: Set<string>; matches: Set<string>; pageViews: number }>();
|
||||
const hourly = new Map<string, { hour: string; visitors: Set<string>; matches: Set<string> }>();
|
||||
const bucket = (date: Date) => {
|
||||
const day = kstDay(date), hour = new Date(+date + 9 * 3600000).toISOString().slice(0, 13);
|
||||
if (!daily.has(day)) daily.set(day, { day, visitors: new Set(), players: new Set(), matches: new Set(), pageViews: 0 });
|
||||
if (!hourly.has(hour)) hourly.set(hour, { hour, visitors: new Set(), matches: new Set() });
|
||||
return { day: daily.get(day)!, hour: hourly.get(hour)! };
|
||||
};
|
||||
for (const v of visits) { const b = bucket(v.occurredAt); b.day.visitors.add(v.playerId!); b.hour.visitors.add(v.playerId!); b.day.pageViews++; }
|
||||
// Both participants report a friend match. Count the match at its first reported start.
|
||||
const counted = new Set<string>();
|
||||
for (const s of clientStarts) {
|
||||
const b = bucket(s.occurredAt); b.day.players.add(s.playerId!);
|
||||
if (!counted.has(s.matchId!)) { b.day.matches.add(s.matchId!); b.hour.matches.add(s.matchId!); counted.add(s.matchId!); }
|
||||
}
|
||||
const today = dayStart(kstDay(new Date(+end - 1)));
|
||||
const rolling = (days: number) => {
|
||||
const from = today - (days - 1) * dayMs, until = today + dayMs;
|
||||
const rows = allEvents.filter(e => +e.occurredAt >= from && +e.occurredAt < until && qualifies(e.playerId, e.sessionId));
|
||||
return { visitors: unique(rows.filter(e => e.eventType === 'page_view').map(e => e.playerId)),
|
||||
players: unique(rows.filter(e => e.source === 'client' && e.eventType === 'game_started' && (!filters.mode || e.mode === filters.mode)).map(e => e.playerId)) };
|
||||
};
|
||||
const grouping = (key: 'source' | 'device' | 'browser' | 'referrer') => {
|
||||
const groups = new Map<string, { visitors: Set<string>; pageViews: number }>();
|
||||
for (const e of visits) {
|
||||
const metadata = sessionById.get(e.sessionId!)?.metadata ?? e.metadata;
|
||||
const label = metadata?.[key] ?? (key === 'referrer' ? 'direct_or_unknown' : 'unknown');
|
||||
if (!groups.has(label)) groups.set(label, { visitors: new Set(), pageViews: 0 });
|
||||
const g = groups.get(label)!; g.visitors.add(e.playerId!); g.pageViews++;
|
||||
}
|
||||
return [...groups].map(([name, g]) => ({ name, visitors: g.visitors.size, pageViews: g.pageViews }));
|
||||
};
|
||||
const counts = (rows: AnalyticsEvent[], field: 'code' | 'action' | 'screen' | 'outcome') => Object.entries(rows.reduce<Record<string, number>>((out, e) => { const k = e[field] ?? 'unknown'; out[k] = (out[k] ?? 0) + 1; return out; }, {})).map(([name, count]) => ({ name, count }));
|
||||
const first = players.filter(p => p.firstVisitAt && period(p.firstVisitAt) && sessions.some(s => s.playerId === p._id && qualifies(s.playerId, s.sessionId)) && allEvents.some(e => e.playerId === p._id && e.eventType === 'page_view'));
|
||||
const visitActivity = sessions.filter(s => qualifies(s.playerId, s.sessionId)).flatMap(s => (s.activeDays ?? []).map(day => ({ playerId: s.playerId, day })));
|
||||
const samples = await db.collection('analytics_presence_samples').aggregate([
|
||||
{ $match: { occurredAt: { $gte: start, $lt: end }, environment: 'production', source: 'activity_reports' } },
|
||||
{ $group: { _id: null, count: { $sum: 1 }, active: { $max: '$active' } } }, { $project: { _id: 0 } },
|
||||
]).toArray();
|
||||
const metadata = await db.collection('analytics_metadata').findOne({ _id: 'production' as any });
|
||||
const quality = await db.collection('analytics_presence_samples').aggregate([
|
||||
{ $match: { occurredAt: { $gte: start, $lt: end }, environment: 'production', source: 'activity_reports' } },
|
||||
{ $group: { _id: '$restartedAt', failures: { $max: '$failures' }, dropped: { $max: '$dropped' }, rateLimited: { $max: '$rateLimited' } } },
|
||||
{ $group: { _id: null, failures: { $sum: '$failures' }, dropped: { $sum: '$dropped' }, rateLimited: { $sum: '$rateLimited' } } }, { $project: { _id: 0 } },
|
||||
]).toArray();
|
||||
const historicalCollection = quality[0] ?? { failures: 0, dropped: 0, rateLimited: 0 };
|
||||
const integrityIssues: string[] = [];
|
||||
if (historicalCollection.failures || historicalCollection.dropped || historicalCollection.rateLimited) integrityIssues.push('조회 기간의 수집 실패·버림·제한 이력');
|
||||
const newCount = [...visitors].filter(id => { const date = playerById.get(id)?.firstVisitAt; return date && period(date); }).length;
|
||||
const activeTimes = sessions.filter(s => visitSessions.has(s.sessionId)).map(s => s.activeMs);
|
||||
return {
|
||||
range: { start, end, timezone: 'Asia/Seoul', retentionStart: cutoff, collectionStart: metadata?.firstCollectedAt ?? allEvents[0]?.occurredAt ?? null },
|
||||
filterApplicability: { visits: '모드 필터 미적용', live: '모든 과거 기간·분류 필터 미적용', activeTime: '세션 전체 누적값, 기간 경계에서 분할하지 않음' },
|
||||
integrityIssues, historicalCollection,
|
||||
overview: { visitors: visitors.size, visitSessions: visitSessions.size, pageViews: visits.length,
|
||||
newVisitors: ratio(newCount, visitors.size), returningVisitors: ratio(visitors.size - newCount, visitors.size), dau: rolling(1), wau: rolling(7), mau: rolling(30) },
|
||||
daily: [...daily.values()].sort((a, b) => a.day.localeCompare(b.day)).map(d => ({ ...d, visitors: d.visitors.size, players: d.players.size, matches: d.matches.size })),
|
||||
hourly: [...hourly.values()].sort((a, b) => a.hour.localeCompare(b.hour)).map(h => ({ ...h, visitors: h.visitors.size, matches: h.matches.size })),
|
||||
sampledPeak: samples[0] ?? { count: 0, active: null },
|
||||
acquisition: { source: grouping('source'), device: grouping('device'), browser: grouping('browser'), referrer: grouping('referrer'),
|
||||
invitation: { invited: visits.filter(v => sessionById.get(v.sessionId!)?.metadata.invitation).length, total: visits.length } },
|
||||
flow: { screens: counts(events.filter(e => e.eventType === 'screen_view'), 'screen'), actions: counts(events.filter(e => e.eventType === 'ui_action'), 'action') },
|
||||
revisit: { visitCohorts: retention(first.map(p => ({ playerId: p._id, day: kstDay(p.firstVisitAt!) })), visitActivity, now),
|
||||
activeMs: { median: median(activeTimes), distribution: distribution(activeTimes, [30000, 60000, 300000, 600000]) } },
|
||||
clientGameplay: { source: 'client' as const, players: unique(clientGames.map(e => e.playerId)),
|
||||
startedMatches: unique(clientStarts.map(e => e.matchId)), resultReports: clientResults.length,
|
||||
outcomes: counts(clientResults, 'outcome'), actions: null, combinations: null, turns: null, serverVerifiedCompletion: null },
|
||||
errors: { client: counts(events.filter(e => e.eventType === 'client_error'), 'code') },
|
||||
users: [...visitors].map(id => ({ playerId: id, firstVisitAt: playerById.get(id)?.firstVisitAt,
|
||||
lastVisitAt: sessions.filter(s => s.playerId === id).sort((a, b) => +b.lastAt - +a.lastAt)[0]?.lastAt,
|
||||
clientMatches: unique(clientStarts.filter(e => e.playerId === id).map(e => e.matchId)) })).slice(0, 50),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { MongoServerError, type Db } from 'mongodb';
|
||||
import { ACTIVE_WINDOW_MS, RETENTION_MS, VISIT_SESSION_MS } from '../../constants/analytics.js';
|
||||
import type { AnalyticsEvent, Presence, VisitMetadata, VisitSession } from '../shared/analytics-types.js';
|
||||
import type { PlayerDocument } from './stats-store.js';
|
||||
import { kstDay } from './analytics-report.js';
|
||||
export interface AnalyticsStore {
|
||||
db: Db;
|
||||
touch(playerId: string, now: Date, visible: boolean, metadata: VisitMetadata, metadataFinal?: boolean): Promise<VisitSession>;
|
||||
insert(event: AnalyticsEvent): Promise<void>;
|
||||
sample(now: Date, presence: Presence, environment: string, quality?: { restartedAt: Date; failures: number; dropped: number; rateLimited: number }): Promise<void>;
|
||||
cleanup(now: Date): Promise<void>;
|
||||
}
|
||||
export async function createAnalyticsStore(db: Db): Promise<AnalyticsStore> {
|
||||
const players = db.collection<PlayerDocument & { visit?: VisitSession }>('players');
|
||||
const sessions = db.collection<VisitSession & { _id: string }>('analytics_sessions');
|
||||
const events = db.collection<AnalyticsEvent>('analytics_events');
|
||||
const samples = db.collection('analytics_presence_samples');
|
||||
for (const collection of [sessions, events, samples]) await collection.createIndex({ expiresAt: 1 }, { expireAfterSeconds: 0 });
|
||||
await sessions.createIndex({ playerId: 1, lastAt: -1 });
|
||||
await sessions.createIndex({ sessionId: 1 }, { unique: true });
|
||||
for (const field of ['eventType', 'playerId', 'sessionId']) await events.createIndex({ [field]: 1, occurredAt: 1 });
|
||||
await samples.createIndex({ occurredAt: 1 });
|
||||
await players.createIndex({ 'visit.expiresAt': 1 });
|
||||
const cleanup = async (now: Date) => { await players.updateMany({ 'visit.expiresAt': { $lte: now } }, { $unset: { visit: '' } }); };
|
||||
await cleanup(new Date());
|
||||
return {
|
||||
db,
|
||||
cleanup,
|
||||
async touch(playerId, now, visible, metadata, metadataFinal = false) {
|
||||
// The single player document atomically decides the session ID and activity delta.
|
||||
// Snapshot writes to analytics_sessions never decide or increment counters independently.
|
||||
const expired = { $or: [{ $eq: [{ $type: '$visit' }, 'missing'] }, { $lte: ['$visit.lastAt', new Date(+now - VISIT_SESSION_MS)] }] };
|
||||
const fresh: VisitSession = { sessionId: randomUUID(), playerId, startedAt: now, lastAt: now, lastSignalAt: now, visible,
|
||||
activeMs: 0, activeDays: visible ? [kstDay(now)] : [], metadata, metadataReady: metadataFinal, expiresAt: new Date(+now + RETENTION_MS) };
|
||||
const delta = { $cond: [{ $and: [visible, '$visit.visible', { $lte: [{ $subtract: [now, '$visit.lastSignalAt'] }, ACTIVE_WINDOW_MS] }, { $gte: [now, '$visit.lastSignalAt'] }] }, { $subtract: [now, '$visit.lastSignalAt'] }, 0] };
|
||||
const doc = await players.findOneAndUpdate({ _id: playerId }, [{ $set: {
|
||||
visit: { $cond: [expired, { $literal: fresh }, { $mergeObjects: ['$visit', {
|
||||
visible, lastSignalAt: now, lastAt: visible ? now : '$visit.lastAt',
|
||||
activeMs: { $add: ['$visit.activeMs', delta] }, expiresAt: new Date(+now + RETENTION_MS),
|
||||
activeDays: { $setUnion: [{ $filter: { input: { $ifNull: ['$visit.activeDays', []] }, as: 'day', cond: { $gte: ['$$day', kstDay(new Date(+now - RETENTION_MS))] } } }, visible ? [kstDay(now)] : []] },
|
||||
metadata: { $cond: [{ $and: [metadataFinal, { $ne: ['$visit.metadataReady', true] }] }, { $literal: metadata }, '$visit.metadata'] },
|
||||
metadataReady: { $or: [metadataFinal, { $eq: ['$visit.metadataReady', true] }] },
|
||||
}] }] },
|
||||
} }], { returnDocument: 'after' });
|
||||
if (!doc?.visit) throw new Error('UNKNOWN_PLAYER');
|
||||
const visit = doc.visit;
|
||||
await sessions.updateOne({ _id: visit.sessionId }, {
|
||||
$setOnInsert: { sessionId: visit.sessionId, playerId, startedAt: visit.startedAt, ...(!visit.metadataReady ? { metadata: visit.metadata } : {}) },
|
||||
$max: { lastAt: visit.lastAt, lastSignalAt: visit.lastSignalAt, activeMs: visit.activeMs, expiresAt: visit.expiresAt },
|
||||
$set: { visible: visit.visible, ...(visit.metadataReady ? { metadata: visit.metadata, metadataReady: true } : {}) },
|
||||
$addToSet: { activeDays: { $each: visit.activeDays } },
|
||||
}, { upsert: true });
|
||||
return visit;
|
||||
},
|
||||
async insert(event) {
|
||||
try { await events.insertOne({ ...event, expiresAt: new Date(+event.occurredAt + RETENTION_MS) }); }
|
||||
catch (error) { if (!(error instanceof MongoServerError) || error.code !== 11000) throw error; }
|
||||
await db.collection<{ _id: string; firstCollectedAt: Date }>('analytics_metadata').updateOne({ _id: event.environment }, { $min: { firstCollectedAt: event.occurredAt } }, { upsert: true });
|
||||
if (event.eventType === 'page_view' && event.playerId) await players.updateOne({ _id: event.playerId }, { $min: { firstVisitAt: event.occurredAt } });
|
||||
},
|
||||
async sample(now, presence, environment, quality) { await samples.insertOne({ occurredAt: now, active: presence.active, source: presence.source, environment, ...(quality ?? {}), expiresAt: new Date(+now + RETENTION_MS) }); },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { ACTIVITY_INTERVAL_MS, ACTIVE_WINDOW_MS, CAMPAIGN_VALUES, CLIENT_ERROR_CODES, CLIENT_EVENTS, MAX_EVENTS_PER_MINUTE, PRESENCE_INTERVAL_MS, RETENTION_SWEEP_MS, RETRY_INTERVAL_MS, RETRY_MAX_AGE_MS, RETRY_QUEUE_LIMIT, SCREENS, UI_ACTIONS } from '../../constants/analytics.js';
|
||||
import { APP_VERSION } from '../../constants/gameplay.js';
|
||||
import type { AnalyticsEvent, Presence, VisitMetadata, VisitSession } from '../shared/analytics-types.js';
|
||||
import type { AnalyticsStore } from './analytics-store.js';
|
||||
import type { Clock } from './clock.js';
|
||||
import { reject } from './errors.js';
|
||||
const validId = (id: unknown): id is string => typeof id === 'string' && /^[A-Za-z0-9_-]{1,80}$/.test(id);
|
||||
export function normalizeMetadata(value: unknown): VisitMetadata {
|
||||
const v = value && typeof value === 'object' ? value as Record<string, unknown> : {};
|
||||
let referrer: string | undefined;
|
||||
try { const url = new URL(String(v.referrer)); if (['http:', 'https:'].includes(url.protocol)) referrer = url.hostname.toLowerCase().slice(0, 253); } catch {}
|
||||
const campaign = (key: string) => CAMPAIGN_VALUES.includes(v[key] as any) ? String(v[key]) : v[key] ? 'other' : undefined;
|
||||
const browser = typeof v.browser === 'string' && /^(Chrome|Edge|Firefox|Safari|Other)( [0-9]{1,3})?$/.test(v.browser) ? v.browser : 'Other';
|
||||
return { device: ['pc', 'mobile', 'tablet'].includes(String(v.device)) ? String(v.device) : 'pc', browser,
|
||||
source: campaign('source') ?? referrer ?? 'direct_or_unknown', invitation: v.invitation === true,
|
||||
...(referrer ? { referrer } : {}), ...(campaign('medium') ? { medium: campaign('medium') } : {}), ...(campaign('campaign') ? { campaign: campaign('campaign') } : {}) };
|
||||
}
|
||||
export interface AnalyticsIdentity { playerId: string; sessionId?: string; gameSessionId?: string }
|
||||
interface Activity { visible: boolean; lastAt: number; touchedAt?: number; hiddenAt?: number; visit?: VisitSession; metadata: VisitMetadata }
|
||||
interface Job { id: string; receivedAt: number; run: () => Promise<void>; waiters: { resolve: () => void; reject: () => void }[] }
|
||||
export class Analytics {
|
||||
states = new Map<string, Activity>();
|
||||
queue: Job[] = []; running = false; closed = false; timer?: any; sampleTimer?: any;
|
||||
httpRequests = 0;
|
||||
lastCleanup = 0;
|
||||
windows = new Map<string, { at: number; count: number }>();
|
||||
quality = { restartedAt: new Date(), lastSuccessAt: null as Date | null, failures: 0, dropped: 0, rateLimited: 0, retryFailures: 0, resultSaveFailures: 0 };
|
||||
constructor(private store: AnalyticsStore, private clock: Clock, private environment = 'production') {
|
||||
this.quality.restartedAt = new Date(clock.now()); this.scheduleSample();
|
||||
}
|
||||
private activityState(p: AnalyticsIdentity) {
|
||||
let state = this.states.get(p.playerId);
|
||||
if (!state) { state = { visible: true, lastAt: this.clock.now(), metadata: normalizeMetadata({}) }; this.states.set(p.playerId, state); }
|
||||
return state;
|
||||
}
|
||||
private async touch(p: AnalyticsIdentity, at: number, visible: boolean, metadataFinal = false) {
|
||||
const state = this.activityState(p);
|
||||
const metadataPending = metadataFinal && !state.visit?.metadataReady;
|
||||
const hideDue = !visible && (state.hiddenAt === undefined || at - state.hiddenAt >= ACTIVITY_INTERVAL_MS);
|
||||
if (state.visit && !metadataPending && !hideDue && at - (state.touchedAt ?? -Infinity) < ACTIVITY_INTERVAL_MS) { p.sessionId = state.visit.sessionId; return state.visit; }
|
||||
const visit = await this.store.touch(p.playerId, new Date(at), visible, state.metadata, metadataFinal);
|
||||
state.touchedAt = at; if (!visible) state.hiddenAt = at;
|
||||
state.visit = visit; p.sessionId = visit.sessionId; return visit;
|
||||
}
|
||||
private permitted(playerId: string) {
|
||||
const now = this.clock.now(); let w = this.windows.get(playerId);
|
||||
if (!w || now - w.at >= 60000) { w = { at: now, count: 0 }; this.windows.set(playerId, w); }
|
||||
if (++w.count > MAX_EVENTS_PER_MINUTE) { this.quality.rateLimited++; return false; } return true;
|
||||
}
|
||||
async clientEvent(p: AnalyticsIdentity, value: unknown) {
|
||||
const at = this.clock.now();
|
||||
const data = value as Record<string, unknown>;
|
||||
if (!data || !validId(data.eventId) || !CLIENT_EVENTS.includes(data.eventType as any)) reject('INVALID_ANALYTICS_EVENT', '수집 항목이 올바르지 않습니다.');
|
||||
const allowed = ['requestId', 'eventId', 'eventType', 'delayed', ...({
|
||||
page_view: ['screen', 'metadata'], screen_view: ['screen'], ui_action: ['screen', 'action', 'flowId'], client_error: ['screen', 'code'],
|
||||
game_started: ['matchId', 'mode'], game_finished: ['matchId', 'mode', 'outcome', 'reason'],
|
||||
} as Record<string, string[]>)[String(data.eventType)]];
|
||||
if (Object.keys(data).some(k => !allowed.includes(k))) reject('INVALID_ANALYTICS_EVENT', '허용되지 않은 수집 항목입니다.');
|
||||
if (data.screen !== undefined && !SCREENS.includes(data.screen as any)) reject('INVALID_ANALYTICS_EVENT', '화면을 확인하세요.');
|
||||
if (data.eventType === 'screen_view' && !SCREENS.includes(data.screen as any)) reject('INVALID_ANALYTICS_EVENT', '화면이 필요합니다.');
|
||||
if (data.delayed !== undefined && typeof data.delayed !== 'boolean') reject('INVALID_ANALYTICS_EVENT', '수집 상태를 확인하세요.');
|
||||
if (data.eventType === 'page_view' && data.screen !== 'home') reject('INVALID_ANALYTICS_EVENT', '시작 화면 조회만 수집합니다.');
|
||||
if (data.eventType === 'ui_action' && (!UI_ACTIONS.includes(data.action as any) || !validId(data.flowId))) reject('INVALID_ANALYTICS_EVENT', '조작을 확인하세요.');
|
||||
if (data.eventType === 'client_error' && !CLIENT_ERROR_CODES.includes(data.code as any)) reject('INVALID_ANALYTICS_EVENT', '오류 코드를 확인하세요.');
|
||||
const gameSummary = data.eventType === 'game_started' || data.eventType === 'game_finished';
|
||||
if (gameSummary && (!validId(data.matchId) || !['bot', 'friend'].includes(String(data.mode)))) reject('INVALID_ANALYTICS_EVENT', '대전을 확인하세요.');
|
||||
if (data.eventType === 'game_finished' && (!['win', 'loss', 'draw', 'aborted'].includes(String(data.outcome)) || !['hit', 'timeout', 'double_timeout', 'forfeit', 'connection_failed', 'protocol_error'].includes(String(data.reason)))) reject('INVALID_ANALYTICS_EVENT', '결과를 확인하세요.');
|
||||
const state = this.activityState(p);
|
||||
if (data.eventType === 'page_view' && !state.visit?.metadataReady) state.metadata = normalizeMetadata(data.metadata);
|
||||
const id = gameSummary && p.gameSessionId ? `game:${p.gameSessionId}:${data.matchId}:${data.eventType}` : `${p.playerId}:${data.eventId}`;
|
||||
if (!this.permitted(p.playerId)) reject('ANALYTICS_RATE_LIMIT', '수집 요청이 제한되었습니다.');
|
||||
state.lastAt = at;
|
||||
let acknowledgedSession: string | undefined;
|
||||
await this.enqueue(id, at, async () => {
|
||||
const existing = await this.store.db.collection<AnalyticsEvent>('analytics_events').findOne({ _id: id });
|
||||
if (existing) { acknowledgedSession = existing.sessionId; return; }
|
||||
const visit = await this.touch(p, at, state.visible, data.eventType === 'page_view');
|
||||
acknowledgedSession = visit.sessionId;
|
||||
const e: AnalyticsEvent = { _id: id, playerId: p.playerId, sessionId: visit.sessionId, occurredAt: new Date(at), eventType: String(data.eventType),
|
||||
appVersion: APP_VERSION, schemaVersion: 1, environment: this.environment, metadata: visit.metadata,
|
||||
source: 'client', ...(p.gameSessionId ? { gameSessionId: p.gameSessionId } : {}),
|
||||
...(gameSummary ? { matchId: String(data.matchId), mode: data.mode as 'bot' | 'friend' } : {}),
|
||||
...(data.eventType === 'game_finished' ? { outcome: data.outcome as AnalyticsEvent['outcome'], reason: String(data.reason) } : {}),
|
||||
...(data.screen ? { screen: String(data.screen) } : {}), ...(data.action ? { action: String(data.action) } : {}),
|
||||
...(data.code ? { code: String(data.code) } : {}), ...(validId(data.flowId) ? { flowId: data.flowId } : {}), delayed: data.delayed === true };
|
||||
await this.store.insert(e);
|
||||
});
|
||||
return { eventId: data.eventId, sessionId: acknowledgedSession ?? state.visit?.sessionId };
|
||||
}
|
||||
async clientActivity(p: AnalyticsIdentity, value: unknown) {
|
||||
const data = value as { visible?: unknown };
|
||||
if (!data || typeof data.visible !== 'boolean') reject('INVALID_ANALYTICS_EVENT', '화면 상태를 확인하세요.');
|
||||
const s = this.activityState(p), at = this.clock.now(); s.visible = data.visible;
|
||||
if (data.visible) s.lastAt = at;
|
||||
await this.enqueue(`activity:${p.playerId}:${at}:${data.visible}`, at, async () => { await this.touch(p, at, data.visible as boolean); });
|
||||
return { received: true };
|
||||
}
|
||||
enqueue(id: string, receivedAt: number, run: () => Promise<void>): Promise<void> {
|
||||
let job = this.queue.find(j => j.id === id);
|
||||
if (!job) {
|
||||
if (this.closed || this.queue.length >= RETRY_QUEUE_LIMIT) { this.quality.dropped++; return Promise.reject(new Error('QUEUE_FULL')); }
|
||||
job = { id, receivedAt, run, waiters: [] }; this.queue.push(job);
|
||||
}
|
||||
const promise = new Promise<void>((resolve, reject) => job!.waiters.push({ resolve, reject: () => reject(new Error('COLLECTION_FAILED')) }));
|
||||
void this.drain(); return promise;
|
||||
}
|
||||
private async drain() {
|
||||
if (this.running || this.closed || this.timer) return;
|
||||
this.running = true;
|
||||
while (this.queue.length && !this.closed) {
|
||||
const job = this.queue[0];
|
||||
if (this.clock.now() - job.receivedAt > RETRY_MAX_AGE_MS) { this.quality.dropped++; job.waiters.forEach(w => w.reject()); this.queue.shift(); continue; }
|
||||
try {
|
||||
await job.run(); this.quality.lastSuccessAt = new Date(this.clock.now()); job.waiters.forEach(w => w.resolve()); this.queue.shift();
|
||||
} catch {
|
||||
this.quality.failures++; this.quality.retryFailures++;
|
||||
job.waiters.forEach(w => w.reject()); job.waiters = [];
|
||||
this.timer = this.clock.setTimeout(() => { this.timer = undefined; void this.drain(); }, RETRY_INTERVAL_MS); break;
|
||||
}
|
||||
}
|
||||
this.running = false;
|
||||
}
|
||||
live(): Presence {
|
||||
const values = [...this.states.values()];
|
||||
return { connected: null, waitingRooms: null, playingPeople: null, matches: null,
|
||||
active: values.filter(s => s.visible && this.clock.now() - s.lastAt <= ACTIVE_WINDOW_MS).length,
|
||||
source: 'activity_reports', observedAt: new Date(this.clock.now()), lastActivityAt: values.length ? new Date(Math.max(...values.map(s => s.lastAt))) : null };
|
||||
}
|
||||
status() { return { ...this.quality, queued: this.queue.length, httpRequests: this.httpRequests, environment: this.environment, incomplete: !!(this.quality.dropped || this.quality.failures || this.quality.rateLimited || this.queue.length), now: new Date(this.clock.now()) }; }
|
||||
private scheduleSample() {
|
||||
this.sampleTimer = this.clock.setTimeout(() => {
|
||||
const at = this.clock.now(), presence = this.live(), quality = { restartedAt: this.quality.restartedAt, failures: this.quality.failures, dropped: this.quality.dropped, rateLimited: this.quality.rateLimited };
|
||||
void this.enqueue(`sample:${at}`, at, () => this.store.sample(new Date(at), presence, this.environment, quality)).catch(() => {});
|
||||
if (at - this.lastCleanup >= RETENTION_SWEEP_MS) {
|
||||
this.lastCleanup = at; void this.enqueue(`cleanup:${at}`, at, () => this.store.cleanup(new Date(at))).catch(() => {});
|
||||
}
|
||||
if (!this.closed) this.scheduleSample();
|
||||
// Bound idle identity caches without keeping an inactive visit alive.
|
||||
for (const [id, s] of this.states) if (at - s.lastAt > RETRY_MAX_AGE_MS) { this.states.delete(id); this.windows.delete(id); }
|
||||
}, PRESENCE_INTERVAL_MS);
|
||||
}
|
||||
async close() {
|
||||
this.closed = true; this.clock.clearTimeout(this.timer); this.clock.clearTimeout(this.sampleTimer);
|
||||
for (const j of this.queue) j.waiters.forEach(w => w.reject());
|
||||
while (this.running) await new Promise(r => setTimeout(r, 5));
|
||||
this.queue = []; this.states.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export interface Clock {
|
||||
now(): number;
|
||||
setTimeout(callback: () => void, delay: number): any;
|
||||
clearTimeout(timer: any): void;
|
||||
}
|
||||
export const systemClock: Clock = { now: Date.now, setTimeout, clearTimeout };
|
||||
@@ -0,0 +1,54 @@
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import fastifyStatic from '@fastify/static';
|
||||
import { Server } from 'socket.io';
|
||||
import { resolve } from 'node:path';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { createStatsStore } from './stats-store.js';
|
||||
import { systemClock, type Clock } from './clock.js';
|
||||
import { createAnalyticsStore } from './analytics-store.js';
|
||||
import { Analytics } from './analytics.js';
|
||||
import { registerAdmin } from './admin.js';
|
||||
import { createGameSessionStore } from './game-session-store.js';
|
||||
import { createRoomStore } from './rooms.js';
|
||||
import { registerAnalyticsApi, registerGameApi, registerRoomApi } from './game-api.js';
|
||||
import { registerSignaling, type RtcServerOptions } from './signaling.js';
|
||||
import { createRankedService } from './ranked-service.js';
|
||||
import { registerRankedApi } from './ranked-api.js';
|
||||
export interface ServerOptions { mongoUri: string; databaseName: string; clientDirectory: string; clock?: Clock; adminUser?: string; adminPassword?: string; environment?: 'production' | 'test'; rtc?: RtcServerOptions }
|
||||
export async function createGameServer(options: ServerOptions): Promise<FastifyInstance> {
|
||||
const store = await createStatsStore(options);
|
||||
const app = Fastify({ logger: false, bodyLimit: 16384 });
|
||||
const io = new Server(app.server, { maxHttpBufferSize: 16384 });
|
||||
const clock = options.clock ?? systemClock;
|
||||
const gameSessions = await createGameSessionStore(store.db, () => clock.now(), options.environment ?? 'production');
|
||||
const peerRooms = await createRoomStore(store.db, () => clock.now());
|
||||
const ranked = await createRankedService(store.db, clock, options.environment ?? 'production');
|
||||
const signaling = registerSignaling(io, gameSessions, peerRooms, options.rtc, () => clock.now());
|
||||
registerGameApi(app, gameSessions, store, ranked);
|
||||
registerRoomApi(app, gameSessions, peerRooms, signaling, ranked);
|
||||
registerRankedApi(app, gameSessions, ranked);
|
||||
const analyticsStore = await createAnalyticsStore(store.db);
|
||||
const analytics = new Analytics(analyticsStore, clock, options.environment ?? 'production');
|
||||
registerAnalyticsApi(app, gameSessions, analytics);
|
||||
app.addHook('onRequest', async request => { if (!request.url.startsWith('/api/admin/') && !request.url.startsWith('/admin/')) analytics.httpRequests++; });
|
||||
io.use((_socket, next) => next(new Error('USE_SIGNALING')));
|
||||
app.get('/health', async () => ({ status: 'ok' }));
|
||||
const directory = resolve(options.clientDirectory);
|
||||
await app.register(fastifyStatic, { root: directory, serve: false });
|
||||
if (existsSync(directory)) {
|
||||
app.get('/', (_request, reply) => reply.sendFile('index.html'));
|
||||
app.get('/assets/*', (request, reply) => {
|
||||
const path = (request.params as { '*': string })['*'];
|
||||
if (!/^[a-zA-Z0-9_.-]+$/.test(path)) return reply.code(404).send();
|
||||
return reply.sendFile(`assets/${path}`);
|
||||
});
|
||||
}
|
||||
registerAdmin(app, store, analytics, clock, { user: options.adminUser, password: options.adminPassword }, directory);
|
||||
app.addHook('preClose', async () => {
|
||||
await ranked.close();
|
||||
await analytics.close();
|
||||
await new Promise<void>(done => io.close(() => done()));
|
||||
});
|
||||
app.addHook('onClose', async () => { await store.close(); });
|
||||
return app;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export class GameError extends Error {
|
||||
constructor(public code: string, message: string) { super(message); }
|
||||
}
|
||||
export function reject(code: string, message: string): never { throw new GameError(code, message); }
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { FastifyInstance, FastifyRequest } from 'fastify';
|
||||
import { GAME_API_MESSAGES } from '../../constants/recording.js';
|
||||
import type { GameSessionAuth, ResultSubmission } from '../shared/p2p-types.js';
|
||||
import { ApiError, type GameSessionStore, type RegisterMatch } from './game-session-store.js';
|
||||
import type { RoomStore } from './rooms.js';
|
||||
import type { Signaling } from './signaling.js';
|
||||
import type { StatsStore } from './stats-store.js';
|
||||
import type { Analytics } from './analytics.js';
|
||||
import { GameError } from './errors.js';
|
||||
import { ANALYTICS_BATCH_SIZE } from '../../constants/analytics.js';
|
||||
import type { RankedService } from './ranked-service.js';
|
||||
|
||||
export function gameAuth(request: FastifyRequest): GameSessionAuth {
|
||||
const token = request.headers.authorization?.match(/^Bearer ([A-Za-z0-9_-]{43})$/)?.[1];
|
||||
const gameSessionId = request.headers['x-game-session'];
|
||||
if (!token || typeof gameSessionId !== 'string') throw new ApiError(401, 'INVALID_SESSION');
|
||||
return { gameSessionId, token };
|
||||
}
|
||||
export function gameRoute(app: FastifyInstance, method: 'GET' | 'POST', url: string, handler: (request: FastifyRequest) => unknown | Promise<unknown>) {
|
||||
app.route({ method, url, handler: async (request, reply) => {
|
||||
reply.header('Cache-Control', 'no-store');
|
||||
try { return { ok: true, data: await handler(request) }; }
|
||||
catch (error) {
|
||||
const code = error instanceof ApiError || error instanceof GameError ? error.code : 'SERVER_ERROR';
|
||||
const status = error instanceof ApiError ? error.statusCode : error instanceof GameError ? (code === 'ANALYTICS_RATE_LIMIT' ? 429 : 400) : 500;
|
||||
return reply.code(status).send({ ok: false, code, message: GAME_API_MESSAGES[code] ?? '요청을 확인하고 다시 시도하세요.' });
|
||||
}
|
||||
} });
|
||||
}
|
||||
export function registerAnalyticsApi(app: FastifyInstance, sessions: GameSessionStore, analytics: Analytics) {
|
||||
const identity = async (request: FastifyRequest) => {
|
||||
const session = await sessions.get(gameAuth(request));
|
||||
if (!session.playerId) throw new ApiError(401, 'INVALID_SESSION');
|
||||
return { playerId: session.playerId, gameSessionId: session.gameSessionId };
|
||||
};
|
||||
gameRoute(app, 'POST', '/api/analytics/events', async request => {
|
||||
const p = await identity(request), events = (request.body as any)?.events;
|
||||
if (!Array.isArray(events) || !events.length || events.length > ANALYTICS_BATCH_SIZE) throw new ApiError(400, 'INVALID_ANALYTICS_EVENT');
|
||||
const results = [];
|
||||
for (const event of events) results.push(await analytics.clientEvent(p, event));
|
||||
return { events: results };
|
||||
});
|
||||
gameRoute(app, 'POST', '/api/analytics/activity', async request => analytics.clientActivity(await identity(request), request.body));
|
||||
}
|
||||
export function registerGameApi(app: FastifyInstance, store: GameSessionStore, visitors?: StatsStore, ranked?: RankedService) {
|
||||
gameRoute(app, 'POST', '/api/game-sessions', async request => {
|
||||
const body = request.body as { createRequestId?: string; guestToken?: string };
|
||||
if (typeof body?.createRequestId !== 'string' || !/^[A-Za-z0-9_-]{43}$/.test(body.createRequestId)) throw new ApiError(400, 'INVALID_CREATION_KEY');
|
||||
const guest = visitors ? (typeof body.guestToken === 'string' ? await visitors.findGuest(body.guestToken) : null) ?? await visitors.createGuest(body.createRequestId) : undefined;
|
||||
const state = await store.create(body?.createRequestId ?? '', guest?.playerId);
|
||||
return { ...state, ...(guest?.playerId === state.playerId && guest ? { guestToken: guest.guestToken } : {}) };
|
||||
});
|
||||
gameRoute(app, 'GET', '/api/game-session', request => store.get(gameAuth(request)));
|
||||
gameRoute(app, 'POST', '/api/game-session/matches', async request => {
|
||||
const auth = gameAuth(request), state = await store.get(auth);
|
||||
const register = () => store.register(auth, request.body as RegisterMatch);
|
||||
return state.playerId && ranked ? ranked.withAvailable(state.playerId, register) : register();
|
||||
});
|
||||
gameRoute(app, 'POST', '/api/game-session/results', request => store.record(gameAuth(request), request.body as ResultSubmission));
|
||||
}
|
||||
export function registerRoomApi(app: FastifyInstance, store: GameSessionStore, rooms: RoomStore, signaling: Signaling, ranked?: RankedService) {
|
||||
const participant = async (request: FastifyRequest) => {
|
||||
const auth = gameAuth(request); await store.get(auth);
|
||||
return auth;
|
||||
};
|
||||
const available = async <T>(request: FastifyRequest, work: (auth: GameSessionAuth) => Promise<T>) => {
|
||||
const auth = gameAuth(request), state = await store.get(auth);
|
||||
const reserve = async () => {
|
||||
if ((await store.get(auth)).activeMatch) throw new ApiError(409, 'MATCH_PENDING');
|
||||
return work(auth);
|
||||
};
|
||||
return state.playerId && ranked ? ranked.withAvailable(state.playerId, reserve) : reserve();
|
||||
};
|
||||
gameRoute(app, 'POST', '/api/rooms', async request => {
|
||||
return available(request, auth => rooms.create(auth.gameSessionId, (request.body as any)?.requestId));
|
||||
});
|
||||
gameRoute(app, 'POST', '/api/rooms/join', async request => {
|
||||
const room = await available(request, auth => rooms.join(auth.gameSessionId, String((request.body as any)?.code ?? '').trim().toUpperCase()));
|
||||
await signaling.notifyRoom(room.id); return room;
|
||||
});
|
||||
gameRoute(app, 'GET', '/api/rooms/:id', async request => {
|
||||
const auth = await participant(request); return rooms.get((request.params as any).id, auth.gameSessionId);
|
||||
});
|
||||
gameRoute(app, 'POST', '/api/rooms/:id/rematch', async request => {
|
||||
const room = await available(request, auth => rooms.rematch((request.params as any).id, auth.gameSessionId, (request.body as any)?.round));
|
||||
await signaling.notifyRoom(room.id); return room;
|
||||
});
|
||||
gameRoute(app, 'POST', '/api/rooms/:id/leave', async request => {
|
||||
const auth = await participant(request);
|
||||
const room = await rooms.leave((request.params as any).id, auth.gameSessionId);
|
||||
await signaling.notifyRoom(room.id); return room;
|
||||
});
|
||||
gameRoute(app, 'GET', '/api/rtc-config', async request => signaling.rtcConfig(await participant(request), (request.query as any).roomId));
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { MongoServerError, type Db } from 'mongodb';
|
||||
import { GAME_SESSION_TTL_MS } from '../../constants/recording.js';
|
||||
import { emptyPlayerStats, type ActiveMatch, type GameSessionAuth, type GameSessionState, type RecordAck, type ResultSubmission } from '../shared/p2p-types.js';
|
||||
import type { PlayerStats } from '../shared/types.js';
|
||||
import { normalizeAppearance } from '../shared/cosmetics.js';
|
||||
import type { FighterAppearance } from '../shared/cosmetics.js';
|
||||
import type { Pair } from '../shared/types.js';
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(public statusCode: number, public code: string) { super(code); }
|
||||
}
|
||||
export const validId = (value: unknown): value is string => typeof value === 'string' && /^[A-Za-z0-9_-]{1,80}$/.test(value);
|
||||
const hash = (value: string) => createHash('sha256').update(value).digest('hex');
|
||||
interface SessionDocument {
|
||||
_id: string; tokenHash: string; playerId?: string; environment: string; createdAt: Date; updatedAt: Date; expiresAt: Date;
|
||||
stats: PlayerStats; lastAppliedSeq: number;
|
||||
activeMatch?: ActiveMatch & { registerRequestId: string };
|
||||
lastResult?: { matchId: string; seq: number; fingerprint: string; outcome: string; appliedAt: Date };
|
||||
}
|
||||
export interface RegisterMatch extends Omit<ActiveMatch, 'seq'> { requestId: string; expectedPreviousSeq: number }
|
||||
export async function createGameSessionStore(db: Db, now: () => number = Date.now, environment = 'production') {
|
||||
const sessions = db.collection<SessionDocument>('game_sessions');
|
||||
await sessions.createIndex({ expiresAt: 1 }, { expireAfterSeconds: 0 });
|
||||
await sessions.createIndex({ playerId: 1, updatedAt: -1 });
|
||||
const authFilter = (auth: GameSessionAuth) => {
|
||||
if (!auth || !validId(auth.gameSessionId) || typeof auth.token !== 'string' || !/^[A-Za-z0-9_-]{43}$/.test(auth.token)) throw new ApiError(401, 'INVALID_SESSION');
|
||||
return { _id: auth.gameSessionId, tokenHash: hash(auth.token), expiresAt: { $gt: new Date(now()) } };
|
||||
};
|
||||
const read = async (auth: GameSessionAuth) => {
|
||||
const doc = await sessions.findOne(authFilter(auth));
|
||||
if (!doc) throw new ApiError(401, 'INVALID_SESSION');
|
||||
return doc;
|
||||
};
|
||||
const active = (doc: SessionDocument): ActiveMatch | undefined => {
|
||||
if (!doc.activeMatch) return undefined;
|
||||
const { registerRequestId: _, ...match } = doc.activeMatch;
|
||||
return match;
|
||||
};
|
||||
const publicState = (doc: SessionDocument, auth: GameSessionAuth): GameSessionState => ({
|
||||
gameSessionId: auth.gameSessionId, token: auth.token, stats: doc.stats, lastAppliedSeq: doc.lastAppliedSeq,
|
||||
...(doc.playerId ? { playerId: doc.playerId } : {}),
|
||||
...(doc.activeMatch ? { activeMatch: active(doc) } : {}),
|
||||
});
|
||||
return {
|
||||
async create(createRequestId: string, playerId?: string): Promise<GameSessionState> {
|
||||
// This creation key is a 256-bit bearer secret, persisted by the tab before the request.
|
||||
// Domain separation makes an interrupted creation retry return the same identity and token.
|
||||
if (typeof createRequestId !== 'string' || !/^[A-Za-z0-9_-]{43}$/.test(createRequestId)) throw new ApiError(400, 'INVALID_CREATION_KEY');
|
||||
const gameSessionId = hash(`session:${createRequestId}`);
|
||||
const token = createHash('sha256').update(`token:${createRequestId}`).digest('base64url');
|
||||
const at = new Date(now());
|
||||
try {
|
||||
await sessions.updateOne({ _id: gameSessionId }, { $setOnInsert: {
|
||||
tokenHash: hash(token), ...(playerId ? { playerId } : {}), environment, createdAt: at, updatedAt: at,
|
||||
expiresAt: new Date(now() + GAME_SESSION_TTL_MS), stats: emptyPlayerStats(), lastAppliedSeq: 0,
|
||||
} }, { upsert: true });
|
||||
} catch (error) { if (!(error instanceof MongoServerError && error.code === 11000)) throw error; }
|
||||
return publicState(await read({ gameSessionId, token }), { gameSessionId, token });
|
||||
},
|
||||
async get(auth: GameSessionAuth) { return publicState(await read(auth), auth); },
|
||||
async register(auth: GameSessionAuth, request: RegisterMatch): Promise<ActiveMatch> {
|
||||
if (!request || !validId(request.requestId) || !validId(request.matchId) || !['bot', 'friend'].includes(request.mode) || !Number.isSafeInteger(request.expectedPreviousSeq) || request.expectedPreviousSeq < 0) throw new ApiError(400, 'INVALID_REQUEST');
|
||||
const before = await read(auth);
|
||||
const sameRequest = before.activeMatch?.registerRequestId === request.requestId;
|
||||
if (sameRequest) {
|
||||
const m = before.activeMatch!;
|
||||
if (m.matchId !== request.matchId || m.mode !== request.mode || m.roomId !== request.roomId || m.seq !== request.expectedPreviousSeq + 1) throw new ApiError(409, 'REQUEST_CONFLICT');
|
||||
return active(before)!;
|
||||
}
|
||||
const profile = before.playerId ? await db.collection<any>('player_profiles').findOne({ _id: before.playerId }) : null;
|
||||
const ownAppearance = normalizeAppearance(profile?.appearance);
|
||||
let appearances: Pair<FighterAppearance> = [ownAppearance, { characterId: ownAppearance.characterId === 0 ? 1 : 0, colorId: 'white' }];
|
||||
if (request.mode === 'friend') {
|
||||
if (!validId(request.roomId)) throw new ApiError(400, 'INVALID_ROOM');
|
||||
const room = await db.collection<any>('rooms').findOne({ _id: request.roomId, currentMatchId: request.matchId,
|
||||
status: 'paired', expiresAt: { $gt: new Date(now()) },
|
||||
$or: [{ hostGameSessionId: auth.gameSessionId }, { guestGameSessionId: auth.gameSessionId }] });
|
||||
if (!room) throw new ApiError(404, 'ROOM_NOT_FOUND');
|
||||
appearances = room.appearances ?? [normalizeAppearance(undefined), { characterId: 1, colorId: 'white' }];
|
||||
} else if (request.roomId !== undefined) throw new ApiError(400, 'INVALID_ROOM');
|
||||
const match = { matchId: request.matchId, seq: request.expectedPreviousSeq + 1, mode: request.mode,
|
||||
...(request.roomId ? { roomId: request.roomId } : {}), appearances, registerRequestId: request.requestId };
|
||||
const doc = await sessions.findOneAndUpdate({ ...authFilter(auth), lastAppliedSeq: request.expectedPreviousSeq, activeMatch: { $exists: false } }, {
|
||||
$set: { activeMatch: match, updatedAt: new Date(now()), expiresAt: new Date(now() + GAME_SESSION_TTL_MS) },
|
||||
}, { returnDocument: 'after' });
|
||||
if (doc) return active(doc)!;
|
||||
const current = await read(auth);
|
||||
if (current.activeMatch?.registerRequestId === request.requestId && current.activeMatch.matchId === match.matchId && current.activeMatch.seq === match.seq && current.activeMatch.mode === match.mode && current.activeMatch.roomId === match.roomId) return active(current)!;
|
||||
throw new ApiError(409, 'MATCH_PENDING');
|
||||
},
|
||||
async record(auth: GameSessionAuth, input: ResultSubmission): Promise<RecordAck> {
|
||||
if (!input || !validId(input.matchId) || !Number.isSafeInteger(input.seq) || input.seq < 1 || !['bot', 'friend'].includes(input.mode) || !['win', 'loss', 'draw', 'aborted'].includes(input.outcome) || !['hit', 'timeout', 'double_timeout', 'forfeit', 'disconnect', 'double_disconnect', 'connection_failed', 'protocol_error'].includes(input.reason)) throw new ApiError(400, 'INVALID_RESULT');
|
||||
const fingerprint = hash(JSON.stringify([input.matchId, input.seq, input.mode, input.outcome, input.reason]));
|
||||
const prefix = `stats.${input.mode}`, at = new Date(now());
|
||||
const ref = (field: string) => `$${prefix}.${field}`;
|
||||
const changes: Record<string, unknown> = {
|
||||
lastAppliedSeq: input.seq, updatedAt: at, expiresAt: new Date(now() + GAME_SESSION_TTL_MS),
|
||||
lastResult: { $literal: { matchId: input.matchId, seq: input.seq, fingerprint, outcome: input.outcome, appliedAt: at } },
|
||||
};
|
||||
if (input.outcome === 'win') {
|
||||
changes[`${prefix}.wins`] = { $add: [ref('wins'), 1] };
|
||||
changes[`${prefix}.current`] = { $add: [ref('current'), 1] };
|
||||
changes[`${prefix}.best`] = { $max: [ref('best'), { $add: [ref('current'), 1] }] };
|
||||
} else if (input.outcome === 'loss') {
|
||||
changes[`${prefix}.losses`] = { $add: [ref('losses'), 1] }; changes[`${prefix}.current`] = 0;
|
||||
} else if (input.outcome === 'draw') changes[`${prefix}.draws`] = { $add: [ref('draws'), 1] };
|
||||
const doc = await sessions.findOneAndUpdate({ ...authFilter(auth), lastAppliedSeq: input.seq - 1,
|
||||
'activeMatch.matchId': input.matchId, 'activeMatch.seq': input.seq, 'activeMatch.mode': input.mode,
|
||||
}, [{ $set: changes }, { $unset: 'activeMatch' }], { returnDocument: 'after' });
|
||||
if (doc) return { status: 'applied', stats: doc.stats };
|
||||
const current = await read(auth);
|
||||
if (input.seq < current.lastAppliedSeq) return { status: 'stale', stats: current.stats };
|
||||
if (input.seq === current.lastAppliedSeq && current.lastResult?.matchId === input.matchId) {
|
||||
if (current.lastResult.fingerprint !== fingerprint) throw new ApiError(409, 'RESULT_CONFLICT');
|
||||
return { status: 'duplicate', stats: current.stats };
|
||||
}
|
||||
throw new ApiError(409, 'NO_ACTIVE_MATCH');
|
||||
},
|
||||
};
|
||||
}
|
||||
export type GameSessionStore = Awaited<ReturnType<typeof createGameSessionStore>>;
|
||||
@@ -0,0 +1,10 @@
|
||||
import { resolve } from 'node:path';
|
||||
import { createGameServer } from './create-server.js';
|
||||
const { MONGODB_URI, MONGODB_DATABASE = 'dragon_ball', HOST = '0.0.0.0', PORT = '3000', ADMIN_USER, ADMIN_PASSWORD } = process.env;
|
||||
if (!MONGODB_URI) throw new Error('MONGODB_URI 환경 변수가 필요합니다.');
|
||||
const urls = (value: string | undefined) => value?.split(',').map(s => s.trim()).filter(Boolean);
|
||||
const app = await createGameServer({ mongoUri: MONGODB_URI, databaseName: MONGODB_DATABASE, clientDirectory: resolve('dist/client'), adminUser: ADMIN_USER, adminPassword: ADMIN_PASSWORD,
|
||||
rtc: { stunUrls: urls(process.env.STUN_URLS), turnUrls: urls(process.env.TURN_URLS), turnSecret: process.env.TURN_SHARED_SECRET } });
|
||||
await app.listen({ host: HOST, port: Number(PORT) });
|
||||
console.log(`http://${HOST}:${PORT}`);
|
||||
for (const signal of ['SIGINT', 'SIGTERM'] as const) process.once(signal, () => { void app.close(); });
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { FastifyInstance, FastifyRequest } from 'fastify';
|
||||
import { gameAuth, gameRoute } from './game-api.js';
|
||||
import { ApiError, type GameSessionStore } from './game-session-store.js';
|
||||
import type { RankedService } from './ranked-service.js';
|
||||
import type { FighterAppearance } from '../shared/cosmetics.js';
|
||||
import type { ActionId } from '../shared/types.js';
|
||||
|
||||
export function registerRankedApi(app: FastifyInstance, sessions: GameSessionStore, service: RankedService) {
|
||||
const identity = async (request: FastifyRequest) => {
|
||||
const session = await sessions.get(gameAuth(request));
|
||||
if (!session.playerId) throw new ApiError(401, 'INVALID_SESSION');
|
||||
return { playerId: session.playerId, gameSessionId: session.gameSessionId };
|
||||
};
|
||||
gameRoute(app, 'GET', '/api/profile', async request => service.profile((await identity(request)).playerId));
|
||||
gameRoute(app, 'POST', '/api/profile', async request => {
|
||||
const { playerId } = await identity(request);
|
||||
return service.updateProfile(playerId, request.body as { appearance?: FighterAppearance });
|
||||
});
|
||||
gameRoute(app, 'GET', '/api/ranked/leaderboard', async request => service.leaderboard((await identity(request)).playerId));
|
||||
for (const [url, method] of [['status', 'GET'], ['queue', 'POST'], ['cancel', 'POST']] as const) {
|
||||
gameRoute(app, method, `/api/ranked/${url}`, async request => {
|
||||
const { playerId, gameSessionId } = await identity(request);
|
||||
return service[url === 'queue' ? 'join' : url](playerId, gameSessionId);
|
||||
});
|
||||
}
|
||||
gameRoute(app, 'POST', '/api/ranked/action', async request => {
|
||||
const { playerId, gameSessionId } = await identity(request);
|
||||
const body = request.body as { matchId: string; turnId: number; action: ActionId } | undefined;
|
||||
if (!body) throw new ApiError(400, 'INVALID_REQUEST');
|
||||
return service.action(playerId, gameSessionId, body.matchId, body.turnId, body.action);
|
||||
});
|
||||
gameRoute(app, 'POST', '/api/ranked/leave', async request => {
|
||||
const { playerId, gameSessionId } = await identity(request);
|
||||
const body = request.body as { matchId: string } | undefined;
|
||||
if (!body) throw new ApiError(400, 'INVALID_REQUEST');
|
||||
return service.leave(playerId, gameSessionId, body.matchId);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
import { randomInt, randomUUID } from 'node:crypto';
|
||||
import type { Db } from 'mongodb';
|
||||
import { COSMETIC_COLORS, EQUIPMENT_IDS, EYEWEAR_IDS, HAIR_STYLES, SKIN_TONES } from '../../constants/cosmetics.js';
|
||||
import {
|
||||
PROFILE_NAME_NOUNS, PROFILE_NAME_PREFIXES, PROFILE_NAME_SUFFIX_MAX, PROFILE_NAME_SUFFIX_MIN, PROFILE_NAME_VERSION,
|
||||
RANKED_FINISHED_TTL_MS, RANKED_LEADERBOARD_LIMIT, RANKED_QUEUE_TTL_MS, RANKED_SAVE_RETRY_MS,
|
||||
} from '../../constants/ranked.js';
|
||||
import { availableColorIds, availableEquipmentIds, DEFAULT_APPEARANCE, normalizeAppearance, type FighterAppearance } from '../shared/cosmetics.js';
|
||||
import { createMatchEngine, type MatchEngine } from '../shared/match-engine.js';
|
||||
import { emptyPlayerStats, type LocalEndReason } from '../shared/p2p-types.js';
|
||||
import type { Leaderboard, PlayerProfile, RankedStatus } from '../shared/ranked-types.js';
|
||||
import { isLegalAction } from '../shared/rules.js';
|
||||
import type { ActionId, ModeStats, Pair } from '../shared/types.js';
|
||||
import { systemClock, type Clock } from './clock.js';
|
||||
import { ApiError, validId } from './game-session-store.js';
|
||||
|
||||
interface ProfileDocument {
|
||||
_id: string; displayName: string; nameVersion?: number; appearance: FighterAppearance; environment: string; createdAt: Date; updatedAt: Date;
|
||||
}
|
||||
interface TerminalMatch {
|
||||
_id: string; playerIds: Pair<string>; appearances: Pair<FighterAppearance>; winnerIndex: 0 | 1 | null;
|
||||
reason: LocalEndReason; order: number; completedAt: Date; environment: string;
|
||||
}
|
||||
interface QueueEntry { playerId: string; gameSessionId: string; expiresAt: number }
|
||||
interface RankedMatch {
|
||||
id: string; playerIds: Pair<string>; appearances: Pair<FighterAppearance>; engine: MatchEngine;
|
||||
actions: Pair<ActionId | null>; departed: Set<string>; timer?: ReturnType<Clock['setTimeout']>;
|
||||
terminal?: TerminalMatch; saved: boolean; finishedExpiresAt?: Pair<number>;
|
||||
}
|
||||
const emptyStats = (): ModeStats => ({ wins: 0, losses: 0, draws: 0, current: 0, best: 0 });
|
||||
function generatedName(): string {
|
||||
return `${PROFILE_NAME_PREFIXES[randomInt(PROFILE_NAME_PREFIXES.length)]}${PROFILE_NAME_NOUNS[randomInt(PROFILE_NAME_NOUNS.length)]}-${randomInt(PROFILE_NAME_SUFFIX_MIN, PROFILE_NAME_SUFFIX_MAX)}`;
|
||||
}
|
||||
|
||||
// One service owns the queue and all match clocks in the established single-process server.
|
||||
export async function createRankedService(db: Db, clock: Clock = systemClock, environment = 'production') {
|
||||
const profiles = db.collection<ProfileDocument>('player_profiles');
|
||||
const results = db.collection<TerminalMatch>('ranked_matches');
|
||||
// Finish migrating every legacy name before any profile or leaderboard can be served.
|
||||
// The version guard makes interrupted migrations/restarts preserve assigned names.
|
||||
for await (const profile of profiles.find({ nameVersion: { $ne: PROFILE_NAME_VERSION } })) {
|
||||
await profiles.updateOne({ _id: profile._id, nameVersion: { $ne: PROFILE_NAME_VERSION } }, { $set: {
|
||||
displayName: generatedName(), nameVersion: PROFILE_NAME_VERSION, updatedAt: new Date(clock.now()),
|
||||
} });
|
||||
}
|
||||
await results.createIndex({ playerIds: 1, order: 1 });
|
||||
let resultOrder = (await results.find().sort({ order: -1 }).limit(1).next())?.order ?? 0;
|
||||
const queue = new Map<string, QueueEntry>();
|
||||
const byPlayer = new Map<string, RankedMatch>();
|
||||
const matches = new Set<RankedMatch>();
|
||||
let tail: Promise<unknown> = Promise.resolve(), closed = false;
|
||||
function serial<T>(work: () => Promise<T>): Promise<T> {
|
||||
const next = tail.then(() => {
|
||||
if (closed) throw new ApiError(503, 'RANKED_CLOSED');
|
||||
expireQueue(); expireFinished();
|
||||
return work();
|
||||
});
|
||||
tail = next.catch(() => {});
|
||||
return next;
|
||||
}
|
||||
function expireQueue() {
|
||||
for (const [playerId, entry] of queue) if (entry.expiresAt <= clock.now()) queue.delete(playerId);
|
||||
}
|
||||
function expireFinished() {
|
||||
for (const match of matches) {
|
||||
if (!match.saved || !match.finishedExpiresAt) continue;
|
||||
for (const slot of [0, 1] as const) if (match.finishedExpiresAt[slot] <= clock.now()) {
|
||||
const playerId = match.playerIds[slot];
|
||||
if (byPlayer.get(playerId) === match) byPlayer.delete(playerId);
|
||||
match.departed.add(playerId);
|
||||
}
|
||||
if (match.departed.size === 2) {
|
||||
if (match.timer !== undefined) clock.clearTimeout(match.timer);
|
||||
matches.delete(match);
|
||||
}
|
||||
}
|
||||
}
|
||||
async function identity(playerId: string, gameSessionId: string) {
|
||||
if (!validId(playerId) || !validId(gameSessionId) || !await db.collection<any>('game_sessions').findOne({
|
||||
_id: gameSessionId, playerId, expiresAt: { $gt: new Date(clock.now()) },
|
||||
})) throw new ApiError(401, 'INVALID_SESSION');
|
||||
}
|
||||
async function freeForRanked(playerId: string) {
|
||||
const sessions = await db.collection<any>('game_sessions').find({ playerId, expiresAt: { $gt: new Date(clock.now()) } }).toArray();
|
||||
if (sessions.some(session => session.activeMatch) || await db.collection<any>('rooms').findOne({
|
||||
members: { $in: sessions.map(session => session._id) }, open: true, expiresAt: { $gt: new Date(clock.now()) },
|
||||
})) throw new ApiError(409, 'MATCH_PENDING');
|
||||
}
|
||||
function addResult(stats: ModeStats, result: TerminalMatch, playerId: string) {
|
||||
if (result.winnerIndex === null) stats.draws++;
|
||||
else if (result.playerIds[result.winnerIndex] === playerId) {
|
||||
stats.wins++; stats.current++; stats.best = Math.max(stats.best, stats.current);
|
||||
} else { stats.losses++; stats.current = 0; }
|
||||
}
|
||||
async function readProfile(playerId: string): Promise<PlayerProfile> {
|
||||
if (!validId(playerId)) throw new ApiError(400, 'INVALID_PROFILE');
|
||||
const now = new Date(clock.now());
|
||||
await profiles.updateOne({ _id: playerId }, { $setOnInsert: {
|
||||
displayName: generatedName(), nameVersion: PROFILE_NAME_VERSION,
|
||||
appearance: { ...DEFAULT_APPEARANCE }, environment, createdAt: now, updatedAt: now,
|
||||
} }, { upsert: true });
|
||||
const doc = (await profiles.findOne({ _id: playerId }))!;
|
||||
const stats = emptyStats();
|
||||
for await (const result of results.find({ playerIds: playerId }).sort({ order: 1, _id: 1 })) addResult(stats, result, playerId);
|
||||
return { playerId, displayName: doc.displayName, appearance: normalizeAppearance(doc.appearance), stats,
|
||||
unlockedColors: availableColorIds(stats), unlockedEquipment: availableEquipmentIds(stats) };
|
||||
}
|
||||
async function persist(match: RankedMatch) {
|
||||
if (match.saved) return;
|
||||
const state = match.engine.view();
|
||||
if (!state.finished || state.aborted) return;
|
||||
// Immutable per-match record is the only source of ranked stats/rewards. A retry after
|
||||
// an ambiguous Mongo response cannot award either player twice, even on standalone Mongo.
|
||||
match.terminal ??= { _id: match.id, playerIds: match.playerIds, appearances: match.appearances,
|
||||
winnerIndex: state.finished.winnerIndex, reason: state.finished.reason,
|
||||
order: ++resultOrder, completedAt: new Date(clock.now()), environment };
|
||||
try {
|
||||
await results.updateOne({ _id: match.id }, { $setOnInsert: match.terminal }, { upsert: true });
|
||||
match.saved = true;
|
||||
match.engine.saved(emptyPlayerStats());
|
||||
} catch (error) { match.engine.saveFailed(); throw error; }
|
||||
}
|
||||
function schedule(match: RankedMatch) {
|
||||
if (match.timer !== undefined) clock.clearTimeout(match.timer);
|
||||
match.timer = undefined;
|
||||
if (closed || !matches.has(match)) return;
|
||||
const state = match.engine.view();
|
||||
if (state.aborted) return;
|
||||
if (state.phase === 'finished' && match.saved && match.departed.size === 2) return;
|
||||
const at = state.phase === 'countdown' ? state.startsAt : state.phase === 'selecting' ? state.deadline
|
||||
: state.phase === 'revealing' ? state.reveal!.revealEndsAt : match.saved
|
||||
? Math.min(...match.playerIds.map((id, slot) => match.departed.has(id) ? Infinity : match.finishedExpiresAt![slot]))
|
||||
: clock.now() + RANKED_SAVE_RETRY_MS;
|
||||
match.timer = clock.setTimeout(() => {
|
||||
match.timer = undefined;
|
||||
// advance schedules persistence retries itself; don't leak an unhandled timer rejection.
|
||||
void serial(() => advance(match)).catch(() => {});
|
||||
}, Math.max(0, at - clock.now()));
|
||||
}
|
||||
async function advance(match: RankedMatch) {
|
||||
try {
|
||||
let state = match.engine.view();
|
||||
if (state.phase === 'countdown' && clock.now() >= state.startsAt) match.engine.beginTurn(state.startsAt);
|
||||
if (state.phase === 'revealing' && clock.now() >= state.reveal!.revealEndsAt) {
|
||||
match.actions = [null, null]; match.engine.beginTurn(state.reveal!.revealEndsAt);
|
||||
}
|
||||
state = match.engine.view();
|
||||
if (state.phase === 'selecting' && clock.now() >= state.deadline) match.engine.resolve(match.actions);
|
||||
if (match.engine.view().phase === 'finished') {
|
||||
match.finishedExpiresAt ??= [clock.now() + RANKED_FINISHED_TTL_MS, clock.now() + RANKED_FINISHED_TTL_MS];
|
||||
await persist(match);
|
||||
}
|
||||
} finally { schedule(match); }
|
||||
}
|
||||
async function statusView(playerId: string): Promise<RankedStatus> {
|
||||
const match = byPlayer.get(playerId);
|
||||
if (match) await advance(match);
|
||||
const profile = await readProfile(playerId);
|
||||
if (!match) return { status: queue.has(playerId) ? 'queued' : 'idle', profile };
|
||||
const selfIndex = match.playerIds[0] === playerId ? 0 : 1;
|
||||
const state = match.engine.view();
|
||||
if (state.finished) {
|
||||
state.finished.stats.friend = structuredClone(profile.stats);
|
||||
match.finishedExpiresAt![selfIndex] = clock.now() + RANKED_FINISHED_TTL_MS;
|
||||
schedule(match);
|
||||
}
|
||||
state.selfIndex = selfIndex; state.ownAction = match.actions[selfIndex];
|
||||
// Own submissions change this participant's version without revealing whether the
|
||||
// opponent has locked a choice. Engine transitions always move past either value.
|
||||
state.version = state.version * 2 + (state.ownAction === null ? 0 : 1);
|
||||
state.ranked = true; state.appearances = structuredClone(match.appearances);
|
||||
state.opponentLeft = match.departed.has(match.playerIds[1 - selfIndex]);
|
||||
state.opponentPresent = !state.opponentLeft;
|
||||
return { status: 'matched', profile, match: state };
|
||||
}
|
||||
function memberMatch(playerId: string, matchId: string) {
|
||||
if (!validId(matchId)) throw new ApiError(400, 'INVALID_REQUEST');
|
||||
const match = byPlayer.get(playerId);
|
||||
if (!match || match.id !== matchId) throw new ApiError(409, 'NO_ACTIVE_MATCH');
|
||||
return match;
|
||||
}
|
||||
return {
|
||||
async profile(playerId: string) {
|
||||
if (closed) throw new ApiError(503, 'RANKED_CLOSED');
|
||||
return readProfile(playerId);
|
||||
},
|
||||
updateProfile(playerId: string, input: { appearance?: FighterAppearance }): Promise<PlayerProfile> {
|
||||
return serial(async () => {
|
||||
if (!input || typeof input !== 'object' || Array.isArray(input)) throw new ApiError(400, 'INVALID_PROFILE');
|
||||
if (Object.hasOwn(input, 'displayName')) throw new ApiError(403, 'PROFILE_NAME_READONLY');
|
||||
const profile = await readProfile(playerId);
|
||||
const update: Partial<ProfileDocument> = {};
|
||||
if (input.appearance !== undefined) {
|
||||
const appearance = input.appearance;
|
||||
if (!appearance || typeof appearance !== 'object' || Array.isArray(appearance)
|
||||
|| (appearance.characterId !== 0 && appearance.characterId !== 1)
|
||||
|| typeof appearance.colorId !== 'string' || !COSMETIC_COLORS.some(color => color.id === appearance.colorId)) throw new ApiError(400, 'INVALID_APPEARANCE');
|
||||
if (Object.hasOwn(appearance, 'hairStyle') && appearance.hairStyle !== 'default'
|
||||
&& !HAIR_STYLES.some(hair => hair.id === appearance.hairStyle)) throw new ApiError(400, 'INVALID_APPEARANCE');
|
||||
if (Object.hasOwn(appearance, 'skinTone') && !SKIN_TONES.some(tone => tone.id === appearance.skinTone)) throw new ApiError(400, 'INVALID_APPEARANCE');
|
||||
if (Object.hasOwn(appearance, 'equipment') && (!Array.isArray(appearance.equipment)
|
||||
|| new Set(appearance.equipment).size !== appearance.equipment.length
|
||||
|| [...appearance.equipment].some(id => !EQUIPMENT_IDS.includes(id)))) throw new ApiError(400, 'INVALID_APPEARANCE');
|
||||
if (EYEWEAR_IDS.filter(id => appearance.equipment?.includes(id)).length > 1) throw new ApiError(400, 'INVALID_APPEARANCE');
|
||||
if (!availableColorIds(profile.stats).includes(appearance.colorId)) throw new ApiError(403, 'COLOR_LOCKED');
|
||||
// Older clients only send character/color; keep their current skin, hair and equipment.
|
||||
const next = normalizeAppearance({ ...profile.appearance, ...appearance });
|
||||
const unlockedEquipment = availableEquipmentIds(profile.stats);
|
||||
if (next.equipment!.some(id => !unlockedEquipment.includes(id))) throw new ApiError(403, 'EQUIPMENT_LOCKED');
|
||||
update.appearance = next;
|
||||
}
|
||||
if (Object.keys(update).length) await profiles.updateOne({ _id: playerId }, { $set: { ...update, updatedAt: new Date(clock.now()) } });
|
||||
return readProfile(playerId);
|
||||
});
|
||||
},
|
||||
async leaderboard(playerId: string): Promise<Leaderboard> {
|
||||
if (closed) throw new ApiError(503, 'RANKED_CLOSED');
|
||||
await readProfile(playerId);
|
||||
const totals = new Map<string, ModeStats>();
|
||||
for await (const result of results.find().sort({ order: 1, _id: 1 })) for (const id of result.playerIds) {
|
||||
const stats = totals.get(id) ?? emptyStats(); addResult(stats, result, id); totals.set(id, stats);
|
||||
}
|
||||
const entries = (await profiles.find({ _id: { $in: [...totals.keys()] } }).toArray()).map(profile => ({ playerId: profile._id,
|
||||
displayName: profile.displayName, wins: totals.get(profile._id)?.wins ?? 0, best: totals.get(profile._id)?.best ?? 0,
|
||||
})).sort((a, b) => b.best - a.best || b.wins - a.wins || (a.playerId < b.playerId ? -1 : a.playerId > b.playerId ? 1 : 0))
|
||||
.map((entry, index) => ({ rank: index + 1, ...entry }));
|
||||
return { entries: entries.slice(0, RANKED_LEADERBOARD_LIMIT), self: entries.find(entry => entry.playerId === playerId) };
|
||||
},
|
||||
assertAvailable(playerId: string): Promise<void> {
|
||||
return serial(async () => { expireQueue(); if (queue.has(playerId) || byPlayer.has(playerId)) throw new ApiError(409, 'MATCH_PENDING'); });
|
||||
},
|
||||
withAvailable<T>(playerId: string, work: () => Promise<T>): Promise<T> {
|
||||
return serial(async () => {
|
||||
if (queue.has(playerId) || byPlayer.has(playerId)) throw new ApiError(409, 'MATCH_PENDING');
|
||||
return work();
|
||||
});
|
||||
},
|
||||
status(playerId: string, gameSessionId: string): Promise<RankedStatus> {
|
||||
return serial(async () => {
|
||||
await identity(playerId, gameSessionId); expireQueue();
|
||||
const entry = queue.get(playerId);
|
||||
if (entry) { entry.gameSessionId = gameSessionId; entry.expiresAt = clock.now() + RANKED_QUEUE_TTL_MS; }
|
||||
return statusView(playerId);
|
||||
});
|
||||
},
|
||||
join(playerId: string, gameSessionId: string): Promise<RankedStatus> {
|
||||
return serial(async () => {
|
||||
await identity(playerId, gameSessionId); expireQueue();
|
||||
if (byPlayer.has(playerId)) return statusView(playerId);
|
||||
await freeForRanked(playerId); await readProfile(playerId);
|
||||
queue.set(playerId, { playerId, gameSessionId, expiresAt: clock.now() + RANKED_QUEUE_TTL_MS });
|
||||
for (const candidate of queue.values()) {
|
||||
if (candidate.playerId === playerId) continue;
|
||||
try { await identity(candidate.playerId, candidate.gameSessionId); await freeForRanked(candidate.playerId); }
|
||||
catch (error) {
|
||||
if (!(error instanceof ApiError)) throw error;
|
||||
queue.delete(candidate.playerId); continue;
|
||||
}
|
||||
const first = await readProfile(candidate.playerId), second = await readProfile(playerId);
|
||||
const id = randomUUID(), engine = createMatchEngine({ matchId: id, mode: 'friend', selfIndex: 0, clock });
|
||||
const match: RankedMatch = { id, engine, playerIds: [candidate.playerId, playerId],
|
||||
appearances: [first.appearance, second.appearance], actions: [null, null], departed: new Set(), saved: false };
|
||||
engine.start(); matches.add(match);
|
||||
for (const id of match.playerIds) { queue.delete(id); byPlayer.set(id, match); }
|
||||
schedule(match); break;
|
||||
}
|
||||
return statusView(playerId);
|
||||
});
|
||||
},
|
||||
cancel(playerId: string, gameSessionId: string): Promise<RankedStatus> {
|
||||
return serial(async () => {
|
||||
await identity(playerId, gameSessionId); expireQueue();
|
||||
if (byPlayer.has(playerId)) throw new ApiError(409, 'MATCH_PENDING');
|
||||
queue.delete(playerId); return statusView(playerId);
|
||||
});
|
||||
},
|
||||
action(playerId: string, gameSessionId: string, matchId: string, turnId: number, action: ActionId): Promise<RankedStatus> {
|
||||
return serial(async () => {
|
||||
await identity(playerId, gameSessionId);
|
||||
const match = memberMatch(playerId, matchId); await advance(match);
|
||||
const state = match.engine.view(), slot = match.playerIds[0] === playerId ? 0 : 1;
|
||||
if (!Number.isSafeInteger(turnId) || state.phase !== 'selecting' || turnId !== state.turnId
|
||||
|| clock.now() < state.startsAt || clock.now() >= state.deadline || !isLegalAction(action, state.energy[slot])) throw new ApiError(400, 'INVALID_ACTION');
|
||||
if (match.actions[slot] !== null) throw new ApiError(409, 'ACTION_LOCKED');
|
||||
match.actions[slot] = action;
|
||||
return statusView(playerId);
|
||||
});
|
||||
},
|
||||
leave(playerId: string, gameSessionId: string, matchId: string): Promise<RankedStatus> {
|
||||
return serial(async () => {
|
||||
await identity(playerId, gameSessionId);
|
||||
if (!validId(matchId)) throw new ApiError(400, 'INVALID_REQUEST');
|
||||
if (!byPlayer.has(playerId) && await results.findOne({ _id: matchId, playerIds: playerId })) return statusView(playerId);
|
||||
const match = memberMatch(playerId, matchId); await advance(match);
|
||||
if (match.engine.view().phase !== 'finished') {
|
||||
match.engine.finish(match.playerIds[0] === playerId ? 1 : 0, 'forfeit');
|
||||
await advance(match);
|
||||
}
|
||||
match.departed.add(playerId); byPlayer.delete(playerId);
|
||||
if (match.departed.size === 2) matches.delete(match);
|
||||
schedule(match);
|
||||
return statusView(playerId);
|
||||
});
|
||||
},
|
||||
async close(): Promise<void> {
|
||||
if (closed) return;
|
||||
await serial(async () => {
|
||||
closed = true; queue.clear();
|
||||
for (const match of matches) {
|
||||
if (match.timer !== undefined) clock.clearTimeout(match.timer);
|
||||
// Unfinished matches are deliberately not recovered or awarded after shutdown.
|
||||
if (match.engine.view().phase !== 'finished') match.engine.abort('connection_failed');
|
||||
}
|
||||
byPlayer.clear(); matches.clear();
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
export type RankedService = Awaited<ReturnType<typeof createRankedService>>;
|
||||
@@ -0,0 +1,105 @@
|
||||
import { randomInt, randomUUID } from 'node:crypto';
|
||||
import { ROOM_CODE_ALPHABET, ROOM_CODE_LENGTH, WAITING_ROOM_TTL_MS } from '../../constants/session.js';
|
||||
import { MongoServerError, type Db } from 'mongodb';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { ACTIVE_ROOM_TTL_MS } from '../../constants/webrtc.js';
|
||||
import { ApiError, validId } from './game-session-store.js';
|
||||
import type { PeerRoom } from '../shared/p2p-types.js';
|
||||
import { normalizeAppearance } from '../shared/cosmetics.js';
|
||||
import type { FighterAppearance } from '../shared/cosmetics.js';
|
||||
import type { Pair } from '../shared/types.js';
|
||||
interface RoomDocument {
|
||||
_id: string; code: string; hostGameSessionId: string; guestGameSessionId?: string;
|
||||
members: string[]; open: boolean; status: 'waiting' | 'paired' | 'closed'; currentMatchId: string;
|
||||
round: number; rematchReady: string[]; createdAt: Date; expiresAt: Date;
|
||||
appearances?: Pair<FighterAppearance>;
|
||||
}
|
||||
export async function createRoomStore(db: Db, now: () => number = Date.now) {
|
||||
const rooms = db.collection<RoomDocument>('rooms');
|
||||
await rooms.createIndex({ code: 1 }, { unique: true });
|
||||
await rooms.createIndex({ expiresAt: 1 }, { expireAfterSeconds: 0 });
|
||||
await rooms.createIndex({ members: 1 }, { unique: true, partialFilterExpression: { open: true } });
|
||||
const releaseExpired = async (sessionId: string) => {
|
||||
await rooms.updateMany({ members: sessionId, open: true, expiresAt: { $lte: new Date(now()) } }, { $set: { open: false, status: 'closed' } });
|
||||
};
|
||||
function view(doc: RoomDocument, sessionId: string): PeerRoom {
|
||||
return { id: doc._id, code: doc.code, currentMatchId: doc.currentMatchId, round: doc.round,
|
||||
selfIndex: doc.hostGameSessionId === sessionId ? 0 : 1, count: doc.members.length, status: doc.status,
|
||||
expiresAt: +doc.expiresAt, appearances: doc.appearances, rematch: [doc.rematchReady.includes(doc.hostGameSessionId), !!doc.guestGameSessionId && doc.rematchReady.includes(doc.guestGameSessionId)] };
|
||||
}
|
||||
async function read(id: string, sessionId: string) {
|
||||
if (!validId(id) || !validId(sessionId)) throw new ApiError(400, 'INVALID_ROOM');
|
||||
const doc = await rooms.findOne({ _id: id, members: sessionId });
|
||||
if (!doc) throw new ApiError(404, 'ROOM_NOT_FOUND');
|
||||
if (+doc.expiresAt <= now()) throw new ApiError(410, 'ROOM_EXPIRED');
|
||||
return doc;
|
||||
}
|
||||
async function appearance(sessionId: string) {
|
||||
const session = await db.collection<any>('game_sessions').findOne({ _id: sessionId });
|
||||
const profile = session?.playerId ? await db.collection<any>('player_profiles').findOne({ _id: session.playerId }) : null;
|
||||
return normalizeAppearance(profile?.appearance);
|
||||
}
|
||||
return {
|
||||
async create(sessionId: string, requestId: string): Promise<PeerRoom> {
|
||||
if (!validId(sessionId) || !validId(requestId)) throw new ApiError(400, 'INVALID_REQUEST');
|
||||
const id = createHash('sha256').update(`${sessionId}:${requestId}`).digest('hex');
|
||||
const existing = await rooms.findOne({ _id: id });
|
||||
if (existing) return view(await read(id, sessionId), sessionId);
|
||||
await releaseExpired(sessionId);
|
||||
for (let attempt = 0; attempt < 5; attempt++) {
|
||||
const doc: RoomDocument = { _id: id, code: Array.from({ length: ROOM_CODE_LENGTH }, () => ROOM_CODE_ALPHABET[randomInt(ROOM_CODE_ALPHABET.length)]).join(''),
|
||||
hostGameSessionId: sessionId, members: [sessionId], open: true, status: 'waiting', currentMatchId: randomUUID(),
|
||||
round: 1, rematchReady: [], appearances: [await appearance(sessionId), { characterId: 1, colorId: 'white' }], createdAt: new Date(now()), expiresAt: new Date(now() + WAITING_ROOM_TTL_MS) };
|
||||
try { await rooms.insertOne(doc); return view(doc, sessionId); }
|
||||
catch (error) {
|
||||
if (!(error instanceof MongoServerError && error.code === 11000)) throw error;
|
||||
const duplicate = await rooms.findOne({ _id: id }); if (duplicate) return view(duplicate, sessionId);
|
||||
if (await rooms.findOne({ members: sessionId, open: true })) throw new ApiError(409, 'PLAYER_BUSY');
|
||||
}
|
||||
}
|
||||
throw new ApiError(503, 'ROOM_CODE_BUSY');
|
||||
},
|
||||
async join(sessionId: string, code: string): Promise<PeerRoom> {
|
||||
if (!validId(sessionId) || typeof code !== 'string' || !/^[A-Z0-9]{6}$/.test(code)) throw new ApiError(404, 'ROOM_NOT_FOUND');
|
||||
const before = await rooms.findOne({ code });
|
||||
if (!before) throw new ApiError(404, 'ROOM_NOT_FOUND');
|
||||
if (+before.expiresAt <= now()) throw new ApiError(410, 'ROOM_EXPIRED');
|
||||
if (before.hostGameSessionId === sessionId) throw new ApiError(409, 'SELF_JOIN');
|
||||
if (before.guestGameSessionId === sessionId && before.status === 'paired') return view(before, sessionId);
|
||||
await releaseExpired(sessionId);
|
||||
const guestAppearance = await appearance(sessionId);
|
||||
try {
|
||||
const doc = await rooms.findOneAndUpdate({ _id: before._id, open: true, status: 'waiting', guestGameSessionId: { $exists: false }, expiresAt: { $gt: new Date(now()) } }, {
|
||||
$set: { guestGameSessionId: sessionId, status: 'paired', appearances: [normalizeAppearance(before.appearances?.[0]), guestAppearance], expiresAt: new Date(now() + ACTIVE_ROOM_TTL_MS) }, $addToSet: { members: sessionId },
|
||||
}, { returnDocument: 'after' });
|
||||
if (doc) return view(doc, sessionId);
|
||||
} catch (error) {
|
||||
if (error instanceof MongoServerError && error.code === 11000) throw new ApiError(409, 'PLAYER_BUSY');
|
||||
throw error;
|
||||
}
|
||||
const current = await rooms.findOne({ _id: before._id });
|
||||
if (current?.guestGameSessionId === sessionId && current.status === 'paired') return view(current, sessionId);
|
||||
throw new ApiError(409, 'ROOM_FULL');
|
||||
},
|
||||
async get(id: string, sessionId: string) { return view(await read(id, sessionId), sessionId); },
|
||||
async rematch(id: string, sessionId: string, round: number): Promise<PeerRoom> {
|
||||
const before = await read(id, sessionId);
|
||||
if (!Number.isSafeInteger(round) || round < 1) throw new ApiError(400, 'INVALID_REQUEST');
|
||||
if (before.status !== 'paired') throw new ApiError(409, 'ROOM_CLOSED');
|
||||
if (before.round === round + 1) return view(before, sessionId);
|
||||
if (before.round !== round) throw new ApiError(409, 'STALE_ROUND');
|
||||
if (await db.collection<any>('game_sessions').findOne({ _id: { $in: before.members }, activeMatch: { $exists: true } })) throw new ApiError(409, 'MATCH_PENDING');
|
||||
await rooms.updateOne({ _id: id, round, status: 'paired' }, { $addToSet: { rematchReady: sessionId } });
|
||||
await rooms.updateOne({ _id: id, round, status: 'paired', rematchReady: { $all: before.members }, expiresAt: { $gt: new Date(now()) } }, {
|
||||
$set: { currentMatchId: randomUUID(), rematchReady: [], expiresAt: new Date(now() + ACTIVE_ROOM_TTL_MS) }, $inc: { round: 1 },
|
||||
});
|
||||
return view(await read(id, sessionId), sessionId);
|
||||
},
|
||||
async leave(id: string, sessionId: string) {
|
||||
const before = await read(id, sessionId);
|
||||
await rooms.updateOne({ _id: id, members: sessionId }, { $set: { open: false, status: 'closed', expiresAt: new Date(Math.min(+before.expiresAt, now() + 60000)) } });
|
||||
return view(await read(id, sessionId), sessionId);
|
||||
},
|
||||
};
|
||||
}
|
||||
export type RoomStore = Awaited<ReturnType<typeof createRoomStore>>;
|
||||
@@ -0,0 +1,74 @@
|
||||
import { createHmac } from 'node:crypto';
|
||||
import type { Server, Socket } from 'socket.io';
|
||||
import { CONNECT_TIMEOUT_MS, MAX_PEER_MESSAGE_BYTES, TURN_CREDENTIAL_TTL_SECONDS } from '../../constants/webrtc.js';
|
||||
import type { GameSessionAuth, IceConfiguration, SignalingPacket } from '../shared/p2p-types.js';
|
||||
import { ApiError, type GameSessionStore } from './game-session-store.js';
|
||||
import type { RoomStore } from './rooms.js';
|
||||
|
||||
export interface RtcServerOptions { stunUrls?: string[]; turnUrls?: string[]; turnSecret?: string }
|
||||
export function registerSignaling(io: Server, sessions: GameSessionStore, rooms: RoomStore, options: RtcServerOptions = {}, now: () => number = Date.now) {
|
||||
const namespace = io.of('/signal');
|
||||
const timeout = (socket: Socket, ms: number) => {
|
||||
clearTimeout(socket.data.expiryTimer);
|
||||
socket.data.expiryTimer = setTimeout(() => socket.disconnect(true), Math.max(0, ms));
|
||||
socket.data.expiryTimer.unref?.();
|
||||
};
|
||||
async function notifyRoom(roomId: string) {
|
||||
const members = [...namespace.sockets.values()].filter(s => s.data.roomId === roomId && s.connected);
|
||||
for (const socket of members) {
|
||||
try {
|
||||
const room = await rooms.get(roomId, socket.data.auth.gameSessionId);
|
||||
socket.emit('room:state', room);
|
||||
socket.emit('signal:peers', { count: members.length, room });
|
||||
timeout(socket, room.status === 'paired' ? CONNECT_TIMEOUT_MS : room.expiresAt - now());
|
||||
} catch { socket.disconnect(true); }
|
||||
}
|
||||
}
|
||||
namespace.use((socket, next) => {
|
||||
void (async () => {
|
||||
const { gameSessionId, token, roomId } = socket.handshake.auth;
|
||||
const auth = { gameSessionId, token }; await sessions.get(auth);
|
||||
const room = await rooms.get(roomId, gameSessionId);
|
||||
if (room.status === 'closed') throw new ApiError(409, 'ROOM_CLOSED');
|
||||
socket.data.auth = auth; socket.data.roomId = room.id; socket.data.selfIndex = room.selfIndex;
|
||||
next();
|
||||
})().catch(() => next(new Error('방 연결 정보를 확인하세요.')));
|
||||
});
|
||||
namespace.on('connection', socket => {
|
||||
// A reconnect replaces only this participant's short-lived signaling connection.
|
||||
for (const existing of namespace.sockets.values()) if (existing !== socket && existing.data.auth.gameSessionId === socket.data.auth.gameSessionId) existing.disconnect(true);
|
||||
let count = 0, windowAt = now();
|
||||
socket.on('signal', (packet: SignalingPacket, ack?: (value: unknown) => void) => {
|
||||
if (typeof ack !== 'function') return;
|
||||
void (async () => {
|
||||
if (now() - windowAt >= 60000) { count = 0; windowAt = now(); }
|
||||
if (++count > 240) throw new ApiError(429, 'RATE_LIMIT');
|
||||
if (!packet || !['offer', 'answer', 'ice'].includes(packet.type) || !Number.isSafeInteger(packet.epoch) || packet.epoch < 0 || !packet.payload || typeof packet.payload !== 'object' || Buffer.byteLength(JSON.stringify(packet)) > MAX_PEER_MESSAGE_BYTES) throw new ApiError(400, 'INVALID_SIGNAL');
|
||||
const room = await rooms.get(socket.data.roomId, socket.data.auth.gameSessionId);
|
||||
if (room.status !== 'paired' || room.currentMatchId !== packet.matchId) throw new ApiError(409, 'STALE_MATCH');
|
||||
if ((packet.type === 'offer' && room.selfIndex !== 0) || (packet.type === 'answer' && room.selfIndex !== 1)) throw new ApiError(403, 'INVALID_ROLE');
|
||||
const other = [...namespace.sockets.values()].find(s => s.connected && s.data.roomId === room.id && s.data.auth.gameSessionId !== socket.data.auth.gameSessionId);
|
||||
if (!other) throw new ApiError(409, 'PEER_NOT_READY');
|
||||
other.emit('signal', { type: packet.type, matchId: packet.matchId, epoch: packet.epoch, payload: packet.payload });
|
||||
return { ok: true };
|
||||
})().then(ack, error => ack({ ok: false, code: error instanceof ApiError ? error.code : 'SERVER_ERROR' }));
|
||||
});
|
||||
socket.on('disconnect', () => { clearTimeout(socket.data.expiryTimer); void notifyRoom(socket.data.roomId); });
|
||||
void notifyRoom(socket.data.roomId);
|
||||
});
|
||||
return {
|
||||
namespace, notifyRoom,
|
||||
async rtcConfig(auth: GameSessionAuth, roomId: string): Promise<IceConfiguration> {
|
||||
await sessions.get(auth); await rooms.get(roomId, auth.gameSessionId);
|
||||
const iceServers: RTCIceServer[] = [];
|
||||
if (options.stunUrls?.length) iceServers.push({ urls: options.stunUrls });
|
||||
const expiresAt = now() + TURN_CREDENTIAL_TTL_SECONDS * 1000;
|
||||
if (options.turnUrls?.length && options.turnSecret) {
|
||||
const username = `${Math.floor(expiresAt / 1000)}:${auth.gameSessionId}`;
|
||||
iceServers.push({ urls: options.turnUrls, username, credential: createHmac('sha1', options.turnSecret).update(username).digest('base64') });
|
||||
}
|
||||
return { iceServers, expiresAt };
|
||||
},
|
||||
};
|
||||
}
|
||||
export type Signaling = ReturnType<typeof registerSignaling>;
|
||||
@@ -0,0 +1,39 @@
|
||||
import { createHash, randomBytes, randomUUID } from 'node:crypto';
|
||||
import { MongoClient, MongoServerError, type Db } from 'mongodb';
|
||||
import type { GuestSession } from '../shared/types.js';
|
||||
export interface PlayerDocument {
|
||||
_id: string; guestTokenHash: string; createdAt: Date; firstVisitAt?: Date;
|
||||
}
|
||||
export interface StatsStore {
|
||||
db: Db;
|
||||
createGuest(creationKey?: string): Promise<GuestSession>;
|
||||
findGuest(token: string): Promise<GuestSession | null>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
const hash = (token: string) => createHash('sha256').update(token).digest('hex');
|
||||
export async function createStatsStore(options: { mongoUri: string; databaseName: string }): Promise<StatsStore> {
|
||||
const client = new MongoClient(options.mongoUri, { serverSelectionTimeoutMS: 5000 });
|
||||
try {
|
||||
await client.connect();
|
||||
const db = client.db(options.databaseName);
|
||||
const players = db.collection<PlayerDocument>('players');
|
||||
await players.createIndex({ guestTokenHash: 1 }, { unique: true });
|
||||
return {
|
||||
db,
|
||||
async createGuest(creationKey) {
|
||||
if (creationKey !== undefined && !/^[A-Za-z0-9_-]{43}$/.test(creationKey)) throw new Error('INVALID_CREATION_KEY');
|
||||
const guestToken = creationKey ? createHash('sha256').update(`visitor-token:${creationKey}`).digest('base64url') : randomBytes(32).toString('base64url');
|
||||
const playerId = creationKey ? hash(`visitor:${creationKey}`) : randomUUID();
|
||||
try { await players.updateOne({ _id: playerId }, { $setOnInsert: { guestTokenHash: hash(guestToken), createdAt: new Date() } }, { upsert: true }); }
|
||||
catch (error) { if (!(error instanceof MongoServerError && error.code === 11000)) throw error; }
|
||||
return { playerId, guestToken };
|
||||
},
|
||||
async findGuest(token) {
|
||||
if (!/^[A-Za-z0-9_-]{43}$/.test(token)) return null;
|
||||
const player = await players.findOne({ guestTokenHash: hash(token) });
|
||||
return player ? { playerId: player._id, guestToken: token } : null;
|
||||
},
|
||||
async close() { await client.close(); },
|
||||
};
|
||||
} catch (error) { await client.close(); throw error; }
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { Mode, PlayerStats } from './types.js';
|
||||
export interface VisitMetadata { device: string; browser: string; source: string; invitation: boolean; medium?: string; campaign?: string; referrer?: string }
|
||||
export interface VisitSession { sessionId: string; playerId: string; startedAt: Date; lastAt: Date; lastSignalAt: Date; visible: boolean; activeMs: number; activeDays: string[]; metadata: VisitMetadata; metadataReady?: boolean; expiresAt: Date }
|
||||
export interface AnalyticsEvent {
|
||||
_id: string; playerId?: string; sessionId?: string; occurredAt: Date; eventType: string; environment: string;
|
||||
schemaVersion: number; appVersion: string; expiresAt?: Date; metadata?: VisitMetadata;
|
||||
screen?: string; action?: string; code?: string; reason?: string; delayed?: boolean;
|
||||
flowId?: string; matchId?: string; mode?: Mode;
|
||||
source?: 'client'; gameSessionId?: string; outcome?: 'win' | 'loss' | 'draw' | 'aborted';
|
||||
}
|
||||
export interface Presence { connected: number | null; active: number; waitingRooms: number | null; playingPeople: number | null; matches: number | null; source?: 'activity_reports'; observedAt?: Date; lastActivityAt?: Date | null }
|
||||
export interface Ratio { numerator: number; denominator: number; percent: number | null }
|
||||
export interface AnalyticsFilters { start: Date; end: Date; mode?: Mode; audience?: 'new' | 'returning'; device?: string; browser?: string; source?: string }
|
||||
export interface Cohort { day: string; size: number; d1: Ratio | null; d7: Ratio | null }
|
||||
export interface UserDetail { playerId: string; firstVisitAt?: Date; lastVisitAt?: Date; gameSessions: { gameSessionId: string; stats: PlayerStats; createdAt: Date; updatedAt: Date; expiresAt: Date }[]; sessions: VisitSession[]; events: AnalyticsEvent[]; page: number; pageSize: number; hasMore: boolean }
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { ActionId } from './types.js';
|
||||
import { ACTION_IDS } from '../../constants/gameplay.js';
|
||||
import { isLegalAction } from './rules.js';
|
||||
export function chooseBotAction(ownEnergy: number, opponentEnergy: number, random = Math.random): ActionId {
|
||||
const choices = ACTION_IDS.filter(a => isLegalAction(a, ownEnergy));
|
||||
const weighted = opponentEnergy === 0 ? ['charge' as const, 'charge' as const, ...choices] : choices;
|
||||
return weighted[Math.min(weighted.length - 1, Math.max(0, Math.floor(random() * weighted.length)))];
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { COSMETIC_COLORS, COSMETIC_EQUIPMENT, EQUIPMENT_IDS, EYEWEAR_IDS, HAIR_STYLES, SKIN_TONES, type EquipmentId, type HairStyle, type SkinToneId } from '../../constants/cosmetics.js';
|
||||
|
||||
export interface FighterAppearance { characterId: 0 | 1; colorId: string; hairStyle?: HairStyle; skinTone?: SkinToneId; equipment?: EquipmentId[] }
|
||||
|
||||
export const DEFAULT_APPEARANCE: FighterAppearance = { characterId: 0, colorId: 'white', hairStyle: 'black', skinTone: 'light', equipment: [] };
|
||||
|
||||
export function normalizeAppearance(value: unknown): FighterAppearance {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return { ...DEFAULT_APPEARANCE, equipment: [] };
|
||||
const appearance = value as Partial<FighterAppearance>;
|
||||
const characterId = appearance.characterId === 0 || appearance.characterId === 1 ? appearance.characterId : DEFAULT_APPEARANCE.characterId;
|
||||
const equipment = EQUIPMENT_IDS.filter(id => Array.isArray(appearance.equipment) && appearance.equipment.includes(id));
|
||||
const eyewear = EYEWEAR_IDS.find(id => equipment.includes(id));
|
||||
return {
|
||||
characterId,
|
||||
colorId: COSMETIC_COLORS.some(color => color.id === appearance.colorId) ? appearance.colorId! : DEFAULT_APPEARANCE.colorId,
|
||||
hairStyle: HAIR_STYLES.find(hair => hair.id === appearance.hairStyle)?.id ?? (characterId === 0 ? 'black' : 'white'),
|
||||
skinTone: SKIN_TONES.find(tone => tone.id === appearance.skinTone)?.id ?? (characterId === 0 ? 'light' : 'tan'),
|
||||
equipment: equipment.filter(id => !EYEWEAR_IDS.some(eye => eye === id) || id === eyewear),
|
||||
};
|
||||
}
|
||||
|
||||
export function appearanceKey(value?: FighterAppearance): string {
|
||||
if (!value) return '';
|
||||
const appearance = normalizeAppearance(value);
|
||||
return `${appearance.characterId}:${appearance.colorId}:${appearance.skinTone}:${appearance.hairStyle}:${appearance.equipment!.join(',')}`;
|
||||
}
|
||||
|
||||
export function availableEquipmentIds(stats: { wins: number; best: number }): EquipmentId[] {
|
||||
return COSMETIC_EQUIPMENT.filter(({ requirement }) => (requirement.type === 'wins' ? stats.wins : stats.best) >= requirement.value).map(item => item.id);
|
||||
}
|
||||
|
||||
export function availableColorIds(stats: { wins: number; best: number }): string[] {
|
||||
return COSMETIC_COLORS.filter(({ requirement }) => !requirement
|
||||
|| (requirement.type === 'wins' ? stats.wins : stats.best) >= requirement.value).map(color => color.id);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { MATCH_COUNTDOWN_MS, REVEAL_DURATION_MS, TURN_DURATION_MS } from '../../constants/gameplay.js';
|
||||
import { chooseBotAction } from './bot.js';
|
||||
import { isLegalAction, resolveTurn } from './rules.js';
|
||||
import { emptyPlayerStats, type LocalEndReason, type LocalMatchState, type MatchCheckpoint, type MatchConfig } from './p2p-types.js';
|
||||
import type { ActionId, Pair, PlayerStats, TurnResolution } from './types.js';
|
||||
|
||||
export function createMatchEngine(config: MatchConfig) {
|
||||
let bot: ActionId | null = null;
|
||||
let state: LocalMatchState = {
|
||||
matchId: config.matchId, mode: config.mode, selfIndex: config.selfIndex, phase: 'countdown',
|
||||
version: 0, energy: [0, 0], turnId: 0, clockNow: config.clock.now(), startsAt: Infinity, deadline: 0,
|
||||
ownAction: null, rematch: [false, false], opponentPresent: true, opponentLeft: false,
|
||||
saveStatus: 'pending', aborted: false,
|
||||
};
|
||||
function finish(winnerIndex: 0 | 1 | null, reason: LocalEndReason, aborted = false) {
|
||||
if (state.phase === 'finished') return;
|
||||
state.phase = 'finished'; state.aborted = aborted; state.version++;
|
||||
state.finished = { matchId: config.matchId, winnerIndex, reason, stats: emptyPlayerStats(),
|
||||
revealEndsAt: state.reveal?.revealEndsAt ?? config.clock.now(), saved: false };
|
||||
}
|
||||
return {
|
||||
start(startsAt = config.clock.now()) {
|
||||
if (state.version !== 0) throw new Error('ALREADY_STARTED');
|
||||
state.startsAt = startsAt + MATCH_COUNTDOWN_MS; state.version++;
|
||||
},
|
||||
beginTurn(startsAt = config.clock.now()): boolean {
|
||||
if (state.phase === 'selecting' || state.phase === 'finished') return false;
|
||||
const earliest = state.phase === 'countdown' ? state.startsAt : state.reveal!.revealEndsAt;
|
||||
if (startsAt < earliest || config.clock.now() < earliest) return false;
|
||||
state.phase = 'selecting'; state.turnId++; state.version++;
|
||||
state.startsAt = startsAt; state.deadline = startsAt + TURN_DURATION_MS;
|
||||
state.ownAction = null; state.reveal = undefined;
|
||||
bot = config.mode === 'bot' ? chooseBotAction(state.energy[1], state.energy[0], config.random) : null;
|
||||
return true;
|
||||
},
|
||||
lockAction(action: ActionId): boolean {
|
||||
if (state.phase !== 'selecting' || config.clock.now() < state.startsAt || config.clock.now() >= state.deadline || state.ownAction || !isLegalAction(action, state.energy[config.selfIndex])) return false;
|
||||
state.ownAction = action; state.version++; return true;
|
||||
},
|
||||
botAction: () => bot,
|
||||
resolve(actions: Pair<ActionId | null>): TurnResolution {
|
||||
if (state.phase !== 'selecting') throw new Error('NOT_SELECTING');
|
||||
if (config.clock.now() < state.deadline) throw new Error('BEFORE_DEADLINE');
|
||||
if (actions.some((a, i) => a !== null && !isLegalAction(a, state.energy[i]))) throw new Error('INVALID_ACTION');
|
||||
if (actions[0] === null || actions[1] === null) {
|
||||
const winner = actions[0] === null && actions[1] === null ? null : actions[0] === null ? 1 : 0;
|
||||
finish(winner, winner === null ? 'double_timeout' : 'timeout');
|
||||
return { energy: [...state.energy], winnerIndex: winner, hitIndex: null };
|
||||
}
|
||||
const result = resolveTurn(state.energy, actions as Pair<ActionId>), now = config.clock.now();
|
||||
state.reveal = { matchId: config.matchId, turnId: state.turnId, actions: [...actions] as Pair<ActionId>,
|
||||
energyBefore: [...state.energy], ...result, revealedAt: now, revealEndsAt: now + REVEAL_DURATION_MS };
|
||||
state.energy = [...result.energy]; state.version++;
|
||||
if (result.winnerIndex !== null) finish(result.winnerIndex, 'hit');
|
||||
else state.phase = 'revealing';
|
||||
return structuredClone(result);
|
||||
},
|
||||
finish,
|
||||
present(revealedAt: number) {
|
||||
if (!Number.isFinite(revealedAt)) throw new Error('INVALID_TIME');
|
||||
if (state.reveal) { state.reveal.revealedAt = revealedAt; state.reveal.revealEndsAt = revealedAt + REVEAL_DURATION_MS; }
|
||||
if (state.finished) state.finished.revealEndsAt = state.reveal?.revealEndsAt ?? revealedAt;
|
||||
state.version++;
|
||||
},
|
||||
presence(present: boolean) { state.opponentPresent = present; state.version++; },
|
||||
requestRematch(slot: 0 | 1) { state.rematch[slot] = true; state.version++; },
|
||||
resumeTurn(checkpoint: MatchCheckpoint, startsAt: number) {
|
||||
if (checkpoint.matchId !== config.matchId || checkpoint.state.selfIndex !== config.selfIndex) throw new Error('MATCH_MISMATCH');
|
||||
if (checkpoint.state.phase === 'finished') throw new Error('ALREADY_FINISHED');
|
||||
state = { ...structuredClone(checkpoint.state), version: Math.max(state.version, checkpoint.state.version) };
|
||||
state.turnId = state.phase === 'revealing' ? state.turnId + 1 : Math.max(1, state.turnId);
|
||||
state.phase = 'selecting'; state.ownAction = null; state.reveal = undefined;
|
||||
state.startsAt = startsAt; state.deadline = startsAt + TURN_DURATION_MS; state.opponentPresent = true; state.version++;
|
||||
},
|
||||
abort(reason: 'connection_failed' | 'protocol_error') { finish(null, reason, true); },
|
||||
saved(stats: PlayerStats) {
|
||||
state.saveStatus = 'saved';
|
||||
if (state.finished) { state.finished.stats = structuredClone(stats); state.finished.saved = true; }
|
||||
state.version++;
|
||||
},
|
||||
saveFailed() { state.saveStatus = 'failed'; state.version++; },
|
||||
view(now = config.clock.now()): LocalMatchState { return structuredClone({ ...state, clockNow: now }); },
|
||||
checkpoint(): MatchCheckpoint { return structuredClone({ matchId: config.matchId, state: { ...state, clockNow: config.clock.now() }, botAction: bot }); },
|
||||
restore(checkpoint: MatchCheckpoint) {
|
||||
if (checkpoint.matchId !== config.matchId || checkpoint.state.matchId !== config.matchId || checkpoint.state.mode !== config.mode || checkpoint.state.selfIndex !== config.selfIndex) throw new Error('MATCH_MISMATCH');
|
||||
state = { ...structuredClone(checkpoint.state), version: Math.max(state.version, checkpoint.state.version) + 1 }; bot = checkpoint.botAction;
|
||||
},
|
||||
};
|
||||
}
|
||||
export type MatchEngine = ReturnType<typeof createMatchEngine>;
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { ActionId, EndReason, MatchFinished, MatchState, Mode, Pair, PlayerStats } from './types.js';
|
||||
import type { FighterAppearance } from './cosmetics.js';
|
||||
|
||||
export type LocalOutcome = 'win' | 'loss' | 'draw' | 'aborted';
|
||||
export type LocalEndReason = EndReason | 'connection_failed' | 'protocol_error';
|
||||
export interface ActiveMatch { matchId: string; seq: number; mode: Mode; roomId?: string; appearances?: Pair<FighterAppearance> }
|
||||
export interface ResultSubmission extends ActiveMatch { outcome: LocalOutcome; reason: LocalEndReason }
|
||||
export interface RecordAck { status: 'applied' | 'duplicate' | 'stale'; stats: PlayerStats }
|
||||
export interface GameSessionAuth { gameSessionId: string; token: string }
|
||||
export interface GameSessionState extends GameSessionAuth {
|
||||
stats: PlayerStats; lastAppliedSeq: number; activeMatch?: ActiveMatch;
|
||||
playerId?: string; guestToken?: string;
|
||||
}
|
||||
export interface PeerRoom {
|
||||
id: string; code: string; currentMatchId: string; round: number; selfIndex: 0 | 1;
|
||||
count: number; status: 'waiting' | 'paired' | 'closed'; expiresAt: number; rematch: Pair<boolean>;
|
||||
appearances?: Pair<FighterAppearance>;
|
||||
}
|
||||
export interface SignalingPacket {
|
||||
type: 'offer' | 'answer' | 'ice'; matchId: string; epoch: number;
|
||||
payload: RTCSessionDescriptionInit | RTCIceCandidateInit;
|
||||
}
|
||||
export interface IceConfiguration { iceServers: RTCIceServer[]; expiresAt: number }
|
||||
export interface LocalMatchState extends Omit<MatchState, 'serverNow' | 'finished'> {
|
||||
clockNow: number; aborted: boolean;
|
||||
appearances?: Pair<FighterAppearance>;
|
||||
ranked?: boolean;
|
||||
finished?: Omit<MatchFinished, 'reason'> & { reason: LocalEndReason };
|
||||
}
|
||||
export interface MatchCheckpoint { matchId: string; state: LocalMatchState; botAction: ActionId | null }
|
||||
export interface MatchConfig {
|
||||
matchId: string; mode: Mode; selfIndex: 0 | 1; clock: { now(): number }; random?: () => number;
|
||||
}
|
||||
export interface PeerHeader {
|
||||
protocolVersion: number; rulesVersion: string; matchId: string; connectionEpoch: number; turnId: number;
|
||||
}
|
||||
export type PeerMessage = PeerHeader & (
|
||||
| { type: 'ready'; slot: 0 | 1 }
|
||||
| { type: 'clock:ping' | 'ping'; sentAt: number }
|
||||
| { type: 'clock:pong' | 'pong'; sentAt: number; remoteAt: number }
|
||||
| { type: 'turn:start'; startsAt: number; resume?: boolean }
|
||||
| { type: 'turn:commit'; digest: string; slot: 0 | 1 }
|
||||
| { type: 'turn:reveal'; action: ActionId | null; nonce: string; slot: 0 | 1 }
|
||||
| { type: 'turn:resolved' | 'turn:ack'; digest: string }
|
||||
| { type: 'turn:present'; revealedAt: number }
|
||||
| { type: 'match:leave' | 'match:leave-ack' | 'match:rematch' }
|
||||
| { type: 'resume' | 'resume:ack'; checkpoint: PublicCheckpoint }
|
||||
);
|
||||
export interface PublicCheckpoint {
|
||||
turnId: number; energy: Pair<number>; finished: boolean; winnerIndex: 0 | 1 | null; reason?: LocalEndReason;
|
||||
}
|
||||
export function emptyPlayerStats(): PlayerStats {
|
||||
const mode = () => ({ wins: 0, losses: 0, draws: 0, current: 0, best: 0 });
|
||||
return { bot: mode(), friend: mode() };
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { RULES_VERSION } from '../../constants/gameplay.js';
|
||||
import { PROTOCOL_VERSION } from '../../constants/webrtc.js';
|
||||
import { isLegalAction } from './rules.js';
|
||||
import type { PeerMessage } from './p2p-types.js';
|
||||
import type { ActionId, Pair } from './types.js';
|
||||
|
||||
export async function digestValue(value: unknown): Promise<string> {
|
||||
const bytes = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(JSON.stringify(value)));
|
||||
return Array.from(new Uint8Array(bytes), b => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
interface ExchangeConfig {
|
||||
matchId: string; epoch: number; turnId: number; slot: 0 | 1; energy: Pair<number>;
|
||||
send(message: PeerMessage): void; onResolved(actions: Pair<ActionId | null>): void;
|
||||
onPhase?: (phase: 'commit' | 'reveal' | 'complete') => void;
|
||||
onPrivateState?: (state: { action: ActionId | null; nonce: string }) => void;
|
||||
}
|
||||
export function createTurnExchange(config: ExchangeConfig) {
|
||||
const other = config.slot === 0 ? 1 : 0;
|
||||
let own: { action: ActionId | null; nonce: string } | undefined, ownDigest: string | undefined, remoteDigest: string | undefined;
|
||||
let revealed = false, received = false, completed = false, remoteAction: ActionId | null = null;
|
||||
let queue = Promise.resolve();
|
||||
const header = { protocolVersion: PROTOCOL_VERSION, rulesVersion: RULES_VERSION, matchId: config.matchId, connectionEpoch: config.epoch, turnId: config.turnId };
|
||||
const commitment = (slot: 0 | 1, action: ActionId | null, nonce: string) => digestValue([config.matchId, config.epoch, config.turnId, slot, action, nonce]);
|
||||
function tryComplete() {
|
||||
if (!revealed || !received || completed) return;
|
||||
completed = true; config.onPhase?.('complete');
|
||||
const actions: Pair<ActionId | null> = [null, null]; actions[config.slot] = own!.action; actions[other] = remoteAction;
|
||||
config.onResolved(actions);
|
||||
}
|
||||
function reveal() {
|
||||
if (!ownDigest || !remoteDigest || revealed) return;
|
||||
revealed = true; config.onPhase?.('reveal');
|
||||
config.send({ ...header, type: 'turn:reveal', slot: config.slot, ...own! }); tryComplete();
|
||||
}
|
||||
async function receive(message: PeerMessage) {
|
||||
if (message.matchId !== config.matchId || message.connectionEpoch !== config.epoch || message.turnId !== config.turnId || message.protocolVersion !== PROTOCOL_VERSION || message.rulesVersion !== RULES_VERSION) return;
|
||||
if (message.type !== 'turn:commit' && message.type !== 'turn:reveal') return;
|
||||
if (message.slot !== other) throw new Error('INVALID_SLOT');
|
||||
if (message.type === 'turn:commit') {
|
||||
if (typeof message.digest !== 'string' || !/^[a-f0-9]{64}$/.test(message.digest)) throw new Error('INVALID_COMMITMENT');
|
||||
if (remoteDigest && remoteDigest !== message.digest) throw new Error('COMMITMENT_CHANGED');
|
||||
remoteDigest = message.digest; reveal(); return;
|
||||
}
|
||||
if (!ownDigest || !remoteDigest) throw new Error('COMMIT_REQUIRED');
|
||||
if (typeof message.nonce !== 'string' || !/^[a-f0-9]{64}$/.test(message.nonce)) throw new Error('INVALID_NONCE');
|
||||
if (await commitment(other, message.action, message.nonce) !== remoteDigest) throw new Error('COMMITMENT_MISMATCH');
|
||||
if (message.action !== null && !isLegalAction(message.action, config.energy[other])) throw new Error('INVALID_ACTION');
|
||||
remoteAction = message.action; received = true; tryComplete();
|
||||
}
|
||||
return {
|
||||
async commit(action: ActionId | null) {
|
||||
if (own) { if (own.action !== action) throw new Error('ACTION_LOCKED'); return; }
|
||||
if (action !== null && !isLegalAction(action, config.energy[config.slot])) throw new Error('INVALID_ACTION');
|
||||
const nonce = Array.from(crypto.getRandomValues(new Uint8Array(32)), b => b.toString(16).padStart(2, '0')).join('');
|
||||
own = { action, nonce }; config.onPrivateState?.(own); config.onPhase?.('commit');
|
||||
ownDigest = await commitment(config.slot, action, nonce);
|
||||
config.send({ ...header, type: 'turn:commit', slot: config.slot, digest: ownDigest }); reveal();
|
||||
},
|
||||
receive(message: PeerMessage) { const operation = queue.then(() => receive(message)); queue = operation.catch(() => {}); return operation; },
|
||||
};
|
||||
}
|
||||
export type TurnExchange = ReturnType<typeof createTurnExchange>;
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { FighterAppearance } from './cosmetics.js';
|
||||
import type { LocalMatchState } from './p2p-types.js';
|
||||
import type { ModeStats } from './types.js';
|
||||
import type { EquipmentId } from '../../constants/cosmetics.js';
|
||||
|
||||
export interface PlayerProfile {
|
||||
playerId: string;
|
||||
displayName: string;
|
||||
appearance: FighterAppearance;
|
||||
stats: ModeStats;
|
||||
unlockedColors: string[];
|
||||
unlockedEquipment: EquipmentId[];
|
||||
}
|
||||
export interface RankedStatus {
|
||||
status: 'idle' | 'queued' | 'matched';
|
||||
profile: PlayerProfile;
|
||||
match?: LocalMatchState;
|
||||
}
|
||||
export interface LeaderboardEntry {
|
||||
rank: number;
|
||||
playerId: string;
|
||||
displayName: string;
|
||||
wins: number;
|
||||
best: number;
|
||||
}
|
||||
export interface Leaderboard { entries: LeaderboardEntry[]; self?: LeaderboardEntry }
|
||||
@@ -0,0 +1,16 @@
|
||||
import { ACTIONS } from '../../constants/gameplay.js';
|
||||
import type { ActionId, Pair, TurnResolution } from './types.js';
|
||||
export function isLegalAction(action: unknown, energy: number): action is ActionId {
|
||||
return typeof action === 'string' && Object.hasOwn(ACTIONS, action)
|
||||
&& Number.isSafeInteger(energy) && energy >= ACTIONS[action as ActionId].cost;
|
||||
}
|
||||
export function resolveTurn(energy: Pair<number>, actions: Pair<string>): TurnResolution {
|
||||
if (!isLegalAction(actions[0], energy[0]) || !isLegalAction(actions[1], energy[1])) throw new Error('INVALID_ACTION');
|
||||
const [a, b] = actions as Pair<ActionId>;
|
||||
const beats = (attack: ActionId, other: ActionId) =>
|
||||
attack === 'blast' ? other === 'charge' : attack === 'beam' && ['charge', 'blast', 'guard'].includes(other);
|
||||
const winnerIndex = beats(a, b) ? 0 : beats(b, a) ? 1 : null;
|
||||
const hitIndex = winnerIndex === null ? null : winnerIndex === 0 ? 1 : 0;
|
||||
const after = energy.map((n, i) => n - ACTIONS[actions[i] as ActionId].cost + (actions[i] === 'charge' && hitIndex !== i ? 1 : 0)) as Pair<number>;
|
||||
return { energy: after, winnerIndex, hitIndex };
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { ACTIONS } from '../../constants/gameplay.js';
|
||||
export type ActionId = keyof typeof ACTIONS;
|
||||
export type Pair<T> = [T, T];
|
||||
export type Mode = 'bot' | 'friend';
|
||||
export type Result = 'win' | 'loss' | 'draw';
|
||||
export type Phase = 'countdown' | 'selecting' | 'revealing' | 'finished';
|
||||
export type EndReason = 'hit' | 'timeout' | 'forfeit' | 'disconnect' | 'double_timeout' | 'double_disconnect';
|
||||
export interface TurnResolution { energy: Pair<number>; winnerIndex: 0 | 1 | null; hitIndex: 0 | 1 | null }
|
||||
export interface TurnReveal extends TurnResolution {
|
||||
matchId: string; turnId: number; actions: Pair<ActionId>; energyBefore: Pair<number>;
|
||||
revealedAt: number; revealEndsAt: number;
|
||||
}
|
||||
export interface ModeStats { current: number; best: number; wins: number; losses: number; draws: number }
|
||||
export type PlayerStats = Record<Mode, ModeStats>;
|
||||
export interface GuestSession { playerId: string; guestToken: string }
|
||||
export interface MatchFinished {
|
||||
matchId: string; winnerIndex: 0 | 1 | null; reason: EndReason; stats: PlayerStats;
|
||||
revealEndsAt: number; saved: boolean;
|
||||
}
|
||||
export interface MatchState {
|
||||
matchId: string; mode: Mode; phase: Phase; version: number; selfIndex: 0 | 1;
|
||||
energy: Pair<number>; turnId: number; serverNow: number; startsAt: number; deadline: number;
|
||||
ownAction: ActionId | null; reveal?: TurnReveal; finished?: MatchFinished;
|
||||
rematch: Pair<boolean>; opponentPresent: boolean; opponentLeft: boolean; saveStatus: 'pending' | 'saved' | 'failed';
|
||||
}
|
||||
export interface RoomState { code: string; count: number; status: 'waiting' | 'playing' | 'finished' | 'closed'; expiresAt: number }
|
||||
export interface SessionReady { guestToken: string; stats: PlayerStats; room: RoomState | null; match: MatchState | null; serverNow: number }
|
||||
export type Ack<T = unknown> = { ok: true; data: T } | { ok: false; code: string; message: string };
|
||||
@@ -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(); } }
|
||||
});
|
||||
@@ -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(); }
|
||||
});
|
||||
@@ -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));
|
||||
});
|
||||
@@ -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'); }
|
||||
});
|
||||
@@ -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: '<svg>', 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(/<svg class="fighter-art"[\s\S]*?<\/svg>/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(/<svg class="fallen-fighter-art[\s\S]*?<\/svg>/)![0];
|
||||
assert.ok(fallen.includes(`data-fallen-index="${hit}"`));
|
||||
check(fallen, hit);
|
||||
} else {
|
||||
for (const name of ['beam-reaction-art', 'beam-dissolve-art']) {
|
||||
const style = html.match(new RegExp(`<svg class="${name}"[^>]*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<string, string> = {};
|
||||
textContent = ''; innerHTML = ''; disabled = false; hidden = false;
|
||||
children = new Map<string, Element>();
|
||||
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 += '<!-- retained -->'; second.innerHTML += '<!-- retained -->';
|
||||
view.update({ ...state, version: 2, appearances: [{ colorId: 'white', characterId: 1 }, { colorId: 'black', characterId: 0 }] });
|
||||
assert.ok(first.innerHTML.endsWith('<!-- retained -->'));
|
||||
assert.ok(second.innerHTML.endsWith('<!-- retained -->'));
|
||||
view.update({ ...state, version: 3, appearances: [{ characterId: 1, colorId: 'red' }, state.appearances[1]] });
|
||||
assert.match(first.innerHTML, /--suit:#ad4148;/);
|
||||
assert.ok(!first.innerHTML.endsWith('<!-- retained -->'));
|
||||
assert.ok(second.innerHTML.endsWith('<!-- retained -->'));
|
||||
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, '상대가 나갔습니다.');
|
||||
});
|
||||
@@ -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('</g>', 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(/<svg class="fighter-art"[\s\S]*?<\/svg>/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(/<svg class="fallen-fighter-art rock-fallen-art"[\s\S]*?<\/svg>/)![0], hit);
|
||||
else for (const name of ['beam-reaction-art', 'beam-dissolve-art']) {
|
||||
const wrapper = html.match(new RegExp(`<svg class="${name}"[^>]+>`))![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('</g>', html.indexOf('class="face face-shock"')), 'eyewear is above all expressions');
|
||||
assert.ok(!overlay.includes('<mask') && !overlay.includes('id='), 'overlay adds no SVG IDs');
|
||||
assert.ok(overlay.includes(eyewear === 'glasses' ? 'fill-opacity=".14"' : 'fill-opacity="1"'), 'clear lenses preserve eyes; dark lenses cover eyes');
|
||||
assert.equal(html.includes('class="bald-scalp"'), hairStyle === 'bald');
|
||||
assert.equal(html.includes('fill="#864237"'), headband);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('active blast and beam portraits, fallen heads and masked remnants use each slot eyewear', () => {
|
||||
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(/<svg class="fighter-art"[\s\S]*?<\/svg>/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(/<svg class="fallen-fighter-art rock-fallen-art"[\s\S]*?<\/svg>/)![0], hit);
|
||||
} else {
|
||||
check(html.match(/<g class="beam-reaction-remnant"[\s\S]*?<\/svg>/)![0], hit);
|
||||
check(html.match(/<g class="fighter beam-remnant"[\s\S]*?<\/svg>/)![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(/<svg class="fighter-art"[\s\S]*?<\/svg>/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(/<svg class="fallen-fighter-art rock-fallen-art"[\s\S]*?<\/svg>/)![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<string, string> = {};
|
||||
textContent = ''; innerHTML = ''; outerHTML = ''; hidden = false;
|
||||
children = new Map<string, Element>();
|
||||
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 += '<!-- untouched -->';
|
||||
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('<!-- untouched -->'));
|
||||
first.innerHTML += '<!-- retained -->';
|
||||
state.appearances![0].equipment = ['headband', 'shoes'];
|
||||
view.update({ ...state, version: 3 });
|
||||
assert.ok(first.innerHTML.endsWith('<!-- retained -->'), '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 += '<!-- retained -->';
|
||||
cutscene.update({ ...state, version: 3 });
|
||||
assert.ok(page.outerHTML.endsWith('<!-- retained -->'));
|
||||
assert.equal(appended.length, 1);
|
||||
} finally { cutscene.destroy(); }
|
||||
});
|
||||
@@ -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<any>('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<any>('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(); } }
|
||||
});
|
||||
@@ -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<string, string>(), 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<string, string>(); 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<string, 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 localFetch: typeof fetch = async (input, init) => {
|
||||
const headers: Record<string, string> = {}; 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(); } }
|
||||
});
|
||||
@@ -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<any>('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(); } }
|
||||
});
|
||||
@@ -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<number, { at: number; callback: () => 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));
|
||||
}
|
||||
}
|
||||
@@ -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(); } }
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
@@ -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<string, Element>();
|
||||
class Element {
|
||||
nodes = new Map<string, Element>();
|
||||
listeners = new Map<string, (event: any) => void>();
|
||||
classList = { remove() {} };
|
||||
disabled = false; textContent = ''; innerHTML = ''; value = '';
|
||||
onclick?: () => void | Promise<void>;
|
||||
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<PlayerProfile>(resolve => { finishOldRead = resolve; });
|
||||
let reads = 0, savedAppearance: FighterAppearance | undefined;
|
||||
const appearances: FighterAppearance[] = [];
|
||||
let savedDisplayed!: () => void;
|
||||
const displayed = new Promise<void>(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<PlayerProfile>(resolve => { finishRead = resolve; });
|
||||
let reads = 0, savedAppearance: FighterAppearance | undefined;
|
||||
let savedDisplayed!: () => void;
|
||||
const displayed = new Promise<void>(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'] }));
|
||||
});
|
||||
}
|
||||
@@ -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<string, string>(), calls: string[] = [], polls = new Map<number, () => 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<string, any>();
|
||||
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']);
|
||||
});
|
||||
@@ -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<string, string>();
|
||||
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(); }
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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<void>, 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<string, 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); } }; 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(); }
|
||||
});
|
||||
@@ -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 = '<label>검증 입력 <select id="test-input"><option value="none">수동</option><option value="charge">충전 반복</option><option value="blast">충전 후 파</option><option value="guard">막기 반복</option></select></label>';
|
||||
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 });
|
||||
}); });
|
||||
}
|
||||
};
|
||||
})();
|
||||
@@ -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<ActionId | null>[] = [], resolvedB: Pair<ActionId | null>[] = [];
|
||||
const base = { matchId: 'match', epoch: 0, turnId: 1, energy: [0, 0] as Pair<number> };
|
||||
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);
|
||||
});
|
||||
@@ -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<string, Function[]>(); 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(); }
|
||||
});
|
||||
@@ -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('<head>', '<head><script src="/__test/peer-monitor.js"></script>'));
|
||||
});
|
||||
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 = '<script>const f=window.fetch;window.fetch=(u,o)=>f(typeof u==="string"&&u.startsWith("/api/admin/analytics/")?"/__test/admin-api/"+u.slice(21):u,o)</script>';
|
||||
return reply.type('text/html; charset=utf-8').send(response.body.replace('<head>', `<head>${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(`<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>모바일 검증</title><style>body{margin:0;background:#dfe4ec}iframe{width:${width}px;height:${height}px;border:0;display:block}</style><iframe title="모바일 게임" src="/"></iframe>`);
|
||||
});
|
||||
app.get('/__test/desktop', (_request, reply) => reply.type('text/html; charset=utf-8').send('<!doctype html><meta charset="utf-8"><title>PC 검증</title><style>body{margin:0;background:#dfe4ec}iframe{width:1280px;height:720px;border:0;display:block}</style><iframe title="PC 게임" src="/"></iframe>'));
|
||||
app.get('/__test/admin-mobile', (_request, reply) => reply.type('text/html; charset=utf-8').send('<!doctype html><meta charset="utf-8"><title>관리자 모바일 검증</title><style>body{margin:0;background:#dfe4ec}iframe{width:390px;height:844px;border:0;display:block}</style><iframe title="관리자 분석" src="/__test/admin"></iframe>'));
|
||||
app.get('/__test/scenarios', async (_request, reply) => {
|
||||
const index = await readFile(clientDirectory + '/index.html', 'utf8');
|
||||
const styles = index.match(/<link[^>]+rel="stylesheet"[^>]*>/g)?.join('') ?? '';
|
||||
return reply.type('text/html; charset=utf-8').send(`<!doctype html><html lang="ko"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>UI 상태 검증</title>${styles}<style>body>.app{min-height:calc(100svh - 60px)}#scenarios{display:flex;align-items:center;gap:8px;height:60px;padding:6px 12px;background:white;position:relative;z-index:20}#scenarios select{font:inherit;font-size:14px;border:1px solid #c5ceca;border-radius:8px;padding:8px;min-width:0;max-width:70%;height:44px}#scenarios button{height:44px;min-height:44px;padding:6px 18px}#scenarios+.app header{height:48px}</style></head><body class="game-page"><nav id="scenarios" aria-label="검증 시나리오"></nav><div class="app" data-screen="match"><header><span class="brand">드래곤볼</span><span>UI 상태 검증</span></header><main id="main"><section id="match"></section></main></div><script type="module" src="/__test/scenarios.js"></script></body></html>`);
|
||||
});
|
||||
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(`<!doctype html><meta charset="utf-8"><title>모바일 상태 검증</title><style>body{margin:0;background:#dfe4ec}iframe{width:${width}px;height:${height}px;border:0;display:block}</style><iframe title="모바일 UI 상태" src="/__test/scenarios"></iframe>`);
|
||||
});
|
||||
app.get('/__test/scenarios-desktop', (_request, reply) => reply.type('text/html; charset=utf-8').send('<!doctype html><meta charset="utf-8"><title>PC 상태 검증</title><style>body{margin:0;background:#dfe4ec}iframe{width:1280px;height:720px;border:0;display:block}</style><iframe title="PC UI 상태" src="/__test/scenarios"></iframe>'));
|
||||
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(); });
|
||||
@@ -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<void>(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<string, Record<string, any>[]> = {
|
||||
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<string, any>, query: Record<string, any> = {}) =>
|
||||
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<string, any>) { return data.find(doc => matches(doc, query)) ?? null; },
|
||||
find(query?: Record<string, any>) {
|
||||
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<string, any>, update: Record<string, any>) {
|
||||
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<void>(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();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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<any>('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 ['손오공', '광고사이트', '<img>', 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<any>('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<any>('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<any>('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<any>('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<any>('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<typeof collection.updateOne>) => {
|
||||
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<any>('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 ['광고사이트', '<script>alert(1)</script>', '', 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<void>(resolve => { release = resolve; });
|
||||
const started = new Promise<void>(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<string, unknown>) => {
|
||||
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<typeof setTimeout>;
|
||||
const progressed = await Promise.race([action.then(() => true), new Promise<false>(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(); }
|
||||
});
|
||||
@@ -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(); }
|
||||
});
|
||||
@@ -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<string, string>(); 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<unknown>[] = [];
|
||||
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(); }
|
||||
});
|
||||
@@ -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<string, string>();
|
||||
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();
|
||||
});
|
||||
@@ -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<any>('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(); } }
|
||||
});
|
||||
@@ -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]);
|
||||
});
|
||||
@@ -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<typeof io> | 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<string>(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(); } }
|
||||
});
|
||||
@@ -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<void>(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<void>((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<any>(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<any>(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<void>(r => io.close(() => r()));
|
||||
try { await db.dropDatabase(); } finally { await client.close(); }
|
||||
}
|
||||
});
|
||||
@@ -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(); }
|
||||
});
|
||||
@@ -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<HTMLElement>('#match')!;
|
||||
const zero = { current: 0, best: 0, wins: 0, losses: 0, draws: 0 };
|
||||
const stats: PlayerStats = { bot: { ...zero }, friend: { ...zero } };
|
||||
let current: LocalMatchState, timer: ReturnType<typeof setTimeout> | undefined;
|
||||
let selfIndex: 0 | 1 = 0;
|
||||
const view = createMatchView(container, async action => {
|
||||
current = { ...current, ownAction: action, version: current.version + 1, clockNow: Date.now() };
|
||||
view.update(current);
|
||||
return true;
|
||||
}, () => prepare(), () => prepare(), () => { document.body.dataset.screen = 'result'; });
|
||||
function state(): LocalMatchState {
|
||||
const now = Date.now();
|
||||
return { matchId: crypto.randomUUID(), mode: 'friend', phase: 'selecting', version: 1, selfIndex, energy: [3, 3], turnId: 1, clockNow: now, startsAt: now, deadline: now + TURN_DURATION_MS, ownAction: null, rematch: [false, false], opponentPresent: true, opponentLeft: false, saveStatus: 'saved', aborted: false };
|
||||
}
|
||||
function show(s: LocalMatchState) { document.body.dataset.screen = 'match'; current = s; view.update(s); }
|
||||
function prepare() {
|
||||
clearTimeout(timer);
|
||||
const s = state(); s.phase = 'countdown'; s.startsAt += MATCH_COUNTDOWN_MS; s.deadline = s.startsAt + TURN_DURATION_MS;
|
||||
show(s);
|
||||
timer = setTimeout(() => show({ ...current, phase: 'selecting', clockNow: Date.now(), version: current.version + 1 }), MATCH_COUNTDOWN_MS);
|
||||
}
|
||||
function reveal(actions: Pair<ActionId>, energy: Pair<number> = [3, 3], elapsed = 0) {
|
||||
clearTimeout(timer);
|
||||
const s = state(), now = Date.now() - elapsed, outcome = resolveTurn(energy, actions);
|
||||
s.energy = outcome.energy; s.phase = outcome.winnerIndex === null ? 'revealing' : 'finished';
|
||||
s.reveal = { ...outcome, matchId: s.matchId, turnId: 1, actions, energyBefore: energy, revealedAt: now, revealEndsAt: now + REVEAL_DURATION_MS };
|
||||
if (outcome.winnerIndex !== null) s.finished = { matchId: s.matchId, winnerIndex: outcome.winnerIndex, reason: 'hit', stats, revealEndsAt: now + REVEAL_DURATION_MS, saved: true };
|
||||
show(s);
|
||||
if (!s.finished) timer = setTimeout(() => show({ ...current, phase: 'selecting', reveal: undefined, turnId: 2, ownAction: null, clockNow: Date.now(), deadline: Date.now() + TURN_DURATION_MS, version: current.version + 1 }), REVEAL_DURATION_MS);
|
||||
}
|
||||
function result(winnerIndex: 0 | 1 | null) {
|
||||
clearTimeout(timer);
|
||||
const s = state(); s.phase = 'finished';
|
||||
s.finished = { matchId: s.matchId, winnerIndex, reason: winnerIndex === null ? 'double_timeout' : 'timeout', stats, revealEndsAt: Date.now(), saved: true };
|
||||
show(s);
|
||||
}
|
||||
const scenarios: [string, () => void][] = [
|
||||
['준비', prepare], ['선택', () => { clearTimeout(timer); show(state()); }],
|
||||
['충전', () => reveal(['charge', 'charge'])], ['파 명중', () => reveal(['blast', 'charge'])],
|
||||
['방어', () => reveal(['guard', 'blast'])], ['상쇄', () => reveal(['blast', 'blast'])],
|
||||
['순간이동', () => reveal(['dodge', 'beam'])], ['에네르기파', () => reveal(['beam', 'guard'])],
|
||||
['왼쪽 피격', () => reveal(['charge', 'blast'])], ['왼쪽 에네르기파 피격', () => reveal(['guard', 'beam'])],
|
||||
['에네르기파 상쇄', () => reveal(['beam', 'beam'])], ['파 회피', () => reveal(['blast', 'dodge'])],
|
||||
['양쪽 막기', () => reveal(['guard', 'guard'])],
|
||||
['피니시 복구', () => reveal(['blast', 'charge'], [3, 3], REVEAL_DURATION_MS + 500)],
|
||||
['컷씬 중간 복구', () => reveal(['beam', 'guard'], [3, 3], 650)],
|
||||
['에네르기파 표정 복구', () => reveal(['beam', 'guard'], [3, 3], 2500)],
|
||||
['에네르기파 소멸 복구', () => reveal(['beam', 'guard'], [3, 3], 3100)],
|
||||
['에네르기파 종료 뒤 복구', () => reveal(['guard', 'beam'], [3, 3], 60_000)],
|
||||
['컷씬 종료 뒤 복구', () => reveal(['charge', 'blast'], [3, 3], 60_000)],
|
||||
['컷씬 저장 응답', () => {
|
||||
reveal(['blast', 'charge']);
|
||||
timer = setTimeout(() => {
|
||||
show({ ...current, version: current.version + 1, clockNow: Date.now() });
|
||||
timer = setTimeout(() => show({ ...current, version: current.version + 1, clockNow: Date.now() }), 4300);
|
||||
}, 700);
|
||||
}],
|
||||
['컷씬 중 새 대전', () => {
|
||||
reveal(['blast', 'charge']);
|
||||
timer = setTimeout(prepare, 900);
|
||||
}],
|
||||
['패배', () => result(selfIndex === 0 ? 1 : 0)], ['승리', () => result(selfIndex)], ['무승부', () => result(null)],
|
||||
['기 부족', () => { clearTimeout(timer); show({ ...state(), energy: [0, 0] }); }],
|
||||
['오른쪽 플레이어', () => { 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();
|
||||
|
||||
@@ -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<string, number> = {};
|
||||
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<unknown>(); 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); })(); });
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user