From 22792bc2b41c3983c29605a44df704979019a5a2 Mon Sep 17 00:00:00 2001 From: Horoli Date: Wed, 14 Oct 2026 19:13:36 +0900 Subject: [PATCH] Prepare game updates and single-process PM2 deployment --- .env.example | 17 ++- README.md | 18 ++- constants/footer.ts | 5 + constants/gameplay.ts | 6 +- constants/global-settings.ts | 4 + constants/ranked.ts | 4 + constants/server.ts | 10 ++ deploy/nginx.conf.example | 48 ++++++ docs/deployment.md | 93 ++++++++++++ ecosystem.config.cjs | 21 +++ src/client/app-footer.css | 41 ++++++ src/client/app-footer.ts | 23 +++ src/client/finish-cutscene.ts | 27 ++-- src/client/friend-match.ts | 44 ++++-- src/client/game.css | 21 ++- src/client/global-settings.ts | 26 ++++ src/client/index.html | 4 +- src/client/lobby-features.css | 2 + src/client/lobby-features.ts | 13 +- src/client/main.ts | 63 ++++++-- src/client/match-controller.ts | 9 +- src/client/match-view.ts | 46 ++++-- src/client/ui-theme.css | 33 ----- src/server/admin.ts | 8 +- src/server/create-server.ts | 19 ++- src/server/game-api.ts | 2 + src/server/game-session-store.ts | 6 +- src/server/global-settings.ts | 35 +++++ src/server/http-operations.ts | 96 ++++++++++++ src/server/index.ts | 30 +++- src/server/ranked-service.ts | 102 ++++++++++--- src/server/rooms.ts | 8 +- src/shared/global-settings.ts | 11 ++ src/shared/match-engine.ts | 14 +- src/shared/match-introduction.ts | 20 +++ src/shared/p2p-types.ts | 5 +- tests/cosmetics.test.ts | 2 +- tests/equipment-art.test.ts | 114 +++++++++++++-- tests/global-settings.test.ts | 88 +++++++++++ tests/http-operations.test.ts | 87 +++++++++++ tests/lobby-profile.test.ts | 55 +++++++ tests/main-recovery.test.ts | 19 ++- tests/match-controller.test.ts | 5 +- tests/match-engine.test.ts | 4 +- tests/match-introduction.test.ts | 22 +++ tests/p2p-recovery.test.ts | 130 +++++++++++++++-- tests/peer-browser-monitor.js | 15 +- tests/ranked.test.ts | 242 +++++++++++++++++++++++++++++-- tests/server.test.ts | 18 +++ tests/ui-scenarios.ts | 8 + 50 files changed, 1546 insertions(+), 197 deletions(-) create mode 100644 constants/footer.ts create mode 100644 constants/global-settings.ts create mode 100644 constants/server.ts create mode 100644 deploy/nginx.conf.example create mode 100644 docs/deployment.md create mode 100644 ecosystem.config.cjs create mode 100644 src/client/app-footer.css create mode 100644 src/client/app-footer.ts create mode 100644 src/client/global-settings.ts create mode 100644 src/server/global-settings.ts create mode 100644 src/server/http-operations.ts create mode 100644 src/shared/global-settings.ts create mode 100644 src/shared/match-introduction.ts create mode 100644 tests/global-settings.test.ts create mode 100644 tests/http-operations.test.ts create mode 100644 tests/match-introduction.test.ts diff --git a/.env.example b/.env.example index e9c31ad..3bb078d 100644 --- a/.env.example +++ b/.env.example @@ -1,13 +1,16 @@ # Fastify + Socket.IO use one application port. -HOST=0.0.0.0 -PORT=3000 +HOST=127.0.0.1 +PORT=2100 -# 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 +# Only trust the local reverse proxy. Leave empty for direct connections. +TRUST_PROXY=loopback +LOG_LEVEL=info -# Proposed application database name; the screenshot's "db" is a connection label. -MONGODB_DATABASE=dragon_ball +# Set credentials/authSource for your private MongoDB deployment. +MONGODB_URI=mongodb://127.0.0.1:27017/?directConnection=true + +# Use a NEW database to start with empty users and records; no old data is deleted. +MONGODB_DATABASE=dragon_ball_production # Set a dedicated test endpoint before database integration tests. # Tests must use a separate, isolated database and never reuse the application database. diff --git a/README.md b/README.md index 96f77c5..6e133f3 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,8 @@ ## 실행 +운영 서버의 Git clone·PM2·HTTPS 설정은 [배포 안내](docs/deployment.md)를 따른다. + Node.js 24 이상, MongoDB Standalone이 필요하다. ```powershell @@ -13,7 +15,7 @@ npm run build npm start ``` -`.env`의 `MONGODB_URI`, `MONGODB_DATABASE`, `HOST`, `PORT`를 설정한다. 기본 접속은 `http://localhost:3000`이다. 외부 브라우저에서는 **HTTPS**가 필요하다. 관리자 인증과 Web Crypto를 포함한 기능을 보호하는 프록시 뒤에 배치한다. +`.env`의 `MONGODB_URI`, `MONGODB_DATABASE`, `HOST`, `PORT`를 설정한다. 기본 접속은 `http://localhost:2100`이다. 외부 브라우저에서는 **HTTPS**가 필요하다. 관리자 인증과 Web Crypto를 포함한 기능을 보호하는 프록시 뒤에 배치한다. Fastify HTTP API·페이지·Socket.IO `/signal`은 한 앱 포트와 MongoDB 연결 풀을 공유한다. 앱 다중 인스턴스의 시그널링 공유는 구현하지 않았다. 개발 중 클라이언트는 다시 빌드하고, 서버는 `npm run dev`로 감시 실행한다. 실행 중인 배포 폴더를 덮어쓰지 않고 새 빌드를 준비한 뒤 교체한다. @@ -29,11 +31,19 @@ Fastify HTTP API·페이지·Socket.IO `/signal`은 한 앱 포트와 MongoDB | 에네르기파 | 3 | 으·파·막기를 맞힘 | | 순간이동 | 1 | 파·에네르기파 회피 | -준비 3초 → 선택 4초 → 공개 1.2초. 기 0으로 시작하며 명중 한 번으로 종료한다. 첫 유효 선택을 잠그고 마감 전에 상대 선택을 공개하지 않는다. 같은 공격은 상쇄되며 공격·회피 비용은 실패해도 소모한다. 봇은 턴 시작 시점의 양쪽 기만 보고 미리 선택한다. +준비 3초 → 선택 최대 3초(기본값) → 공개 1.2초. 봇 대전은 내가 선택하면, 친구·랭킹 대전은 양쪽이 선택하면 즉시 진행한다. 기 0으로 시작하며 명중 한 번으로 종료한다. 첫 유효 선택을 잠그고 양쪽 선택이 확정되기 전에는 상대 선택을 공개하지 않는다. 같은 공격은 상쇄되며 공격·회피 비용은 실패해도 소모한다. 봇은 턴 시작 시점의 양쪽 기만 보고 미리 선택한다. 화면에는 내 기만 표시하고 행동 버튼은 내 캐릭터 아래에 배치한다. 상대 기는 화면에서만 숨긴다. P2P 판정·복원을 위해 양쪽 기가 브라우저에 보관되므로 개발자도구에서의 조회나 공개 행동을 통한 기 추적을 차단하지 않는다. -친구 대전은 마감 후 선택 해시를 교환하고 양쪽 해시가 모이면 선택과 nonce를 공개한다. 양쪽 브라우저가 독립 계산한 결과 해시와 확인 메시지가 일치해야 공개·기록한다. 한쪽 미입력은 패배, 양쪽 미입력은 무승부다. 명시적 나가기를 상대가 확인하면 기권 처리한다. +친구 대전은 선택 즉시 선택 해시를 교환하고 양쪽 해시가 모이면 선택과 nonce를 공개한다. 미선택 상태로 마감되면 미입력 해시를 전송한다. 양쪽 브라우저가 독립 계산한 결과 해시와 확인 메시지가 일치해야 공개·기록한다. 한쪽 미입력은 패배, 양쪽 미입력은 무승부다. 명시적 나가기를 상대가 확인하면 기권 처리한다. + +### 글로벌 설정 + +MongoDB `global_settings`의 `_id: "global"` 문서에 `revision`, `turnDurationMs`(기본 3000), `finishCutsceneSpeed`(기본 1), `matchingDelayMs`(기본 1000), `matchSuccessDelayMs`(기본 1000), `matchPreparationDelayMs`(기본 3000)를 저장한다. 랭킹은 매칭 중 → 성공 → 준비 중, 친구전은 성공 → 준비 중 순서로 표시한다. 준비 화면은 `matchPreparationDelayMs`만큼 표시하며 안내 지연이 첫 턴 선택 시간을 줄이지 않는다. 컷씬 배속 2는 장면 전환과 애니메이션을 두 배 빠르게 재생한다. 컷씬 종료 후 결과 화면으로 자동 전환하며, 최소 결과 공개 시간(1.2초)을 지킨다. + +기존 관리자 Basic 인증으로 `GET /api/admin/global-settings`를 조회하고, 현재 `revision`과 설정값 전체을 `POST /api/admin/global-settings`에 보낸다. 예: `{"revision":1,"turnDurationMs":3000,"finishCutsceneSpeed":2,"matchingDelayMs":1000,"matchSuccessDelayMs":1000,"matchPreparationDelayMs":3000}`. 저장 시 리비전이 자동 증가하며, 다른 관리자가 먼저 변경했다면 409를 반환한다. 선택 시간은 500~60000ms 정수, 컷씬 배속은 0.25~4 범위다. 매칭 중·성공·준비 지연은 각각 0~10000ms 정수다. 기존 문서의 누락 필드는 서버 시작 시 기본값으로 추가된다. DB에서 직접 변경할 때도 값의 범위를 지키고 같은 원자적 업데이트에서 `revision`을 1 증가시킨다. + +설정 변경은 서버 재시작 없이 다음 대전에 적용된다. 친구전은 페어링·재대전 시 같은 설정을 방에 저장하고, 봇·랭킹 대전도 생성 시 설정을 확정한다. 진행 중 대전과 새로고침 복구는 기존 대전의 설정을 유지한다. 브라우저는 최초 로딩·창 포커스/가시성 복귀·대전 시작 시 `GET /api/global-settings?revision=...`로 확인하고, 변경된 경우만 `localStorage` 캐시를 갱신한다. 조회 실패 시 유효한 캐시 또는 기본값을 유지하며 실제 대전에는 서버가 확정한 설정을 사용한다. Socket.IO는 방 대기와 SDP/ICE 협상에만 사용하고 DataChannel 준비 확인 후 닫는다. 정상 대전 중 서버는 게임 객체·행동·판정 타이머를 유지하지 않는다. 서버 재시작 중에도 이미 연결된 대전은 진행하며 HTTP 결과 저장은 복구 후 재시도한다. @@ -101,4 +111,4 @@ npm run build ## 후속 범위 -퀵매치·공개 랭킹·MMR·관전·채팅은 포함하지 않는다. 다중 앱 인스턴스, 분산 브라우저 1,500쌍, 운영 TURN 비용·실제 모바일 네트워크 검증은 별도 환경에서 확인한다. +친구전은 P2P, 랭킹 매칭·판정은 단일 서버 프로세스에서 실행한다. 서버 재시작 시 진행 중 랭킹 경기는 복원하지 않는다. MMR·관전·채팅은 포함하지 않는다. 다중 앱 인스턴스, 분산 브라우저 1,500쌍, 운영 TURN 비용·실제 모바일 네트워크 검증은 별도 환경에서 확인한다. diff --git a/constants/footer.ts b/constants/footer.ts new file mode 100644 index 0000000..471e5b1 --- /dev/null +++ b/constants/footer.ts @@ -0,0 +1,5 @@ +export const FOOTER_LINKS = [ + { id: 'rules', label: 'rule' }, + { id: 'privacy', label: 'privacy' }, + { id: 'contact', label: 'contact' }, +] as const; diff --git a/constants/gameplay.ts b/constants/gameplay.ts index d4646b6..30db73c 100644 --- a/constants/gameplay.ts +++ b/constants/gameplay.ts @@ -1,3 +1,5 @@ +import { DEFAULT_GLOBAL_SETTINGS } from './global-settings.js'; + export const ACTIONS = { charge: { name: '으', cost: 0, effect: '생존 시 기 +1', symbol: '✦' }, blast: { name: '파', cost: 1, effect: '상대의 으를 맞힘', symbol: '➜' }, @@ -7,8 +9,8 @@ export const ACTIONS = { } 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 TURN_DURATION_MS = DEFAULT_GLOBAL_SETTINGS.turnDurationMs; export const REVEAL_DURATION_MS = 1200; -export const RULES_VERSION = '1'; +export const RULES_VERSION = '2'; export const BOT_VERSION = '1'; export const APP_VERSION = '0.1.0'; diff --git a/constants/global-settings.ts b/constants/global-settings.ts new file mode 100644 index 0000000..ad1c25a --- /dev/null +++ b/constants/global-settings.ts @@ -0,0 +1,4 @@ +export const GLOBAL_SETTINGS_CACHE_KEY = 'dragon-ball:global-settings:v1'; +export const GLOBAL_SETTINGS_REQUEST_TIMEOUT_MS = 5000; +export const DEFAULT_GLOBAL_SETTINGS = { revision: 1, turnDurationMs: 3000, finishCutsceneSpeed: 1, matchingDelayMs: 1000, matchSuccessDelayMs: 1000, matchPreparationDelayMs: 3000 }; +export const GLOBAL_SETTINGS_LIMITS = { turnDurationMs: { min: 500, max: 60000 }, finishCutsceneSpeed: { min: 0.25, max: 4 }, matchingDelayMs: { min: 0, max: 10000 }, matchSuccessDelayMs: { min: 0, max: 10000 }, matchPreparationDelayMs: { min: 0, max: 10000 } }; diff --git a/constants/ranked.ts b/constants/ranked.ts index 012fcc3..cfb6556 100644 --- a/constants/ranked.ts +++ b/constants/ranked.ts @@ -3,6 +3,10 @@ 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 RANKED_STATS_CACHE_LIMIT = 1000; +export const RANKED_STATS_CACHE_TTL_MS = 5 * 60 * 1000; +export const RANKED_LEADERBOARD_CACHE_LIMIT = 1000; +export const RANKED_LEADERBOARD_CACHE_TTL_MS = 5000; export const PROFILE_NAME_VERSION = 1; export const PROFILE_NAME_PREFIXES = ['푸른', '밝은', '고요한', '빛나는', '온화한', '맑은', '은빛', '씩씩한'] as const; export const PROFILE_NAME_NOUNS = ['구름', '바람', '별빛', '노을', '하늘', '달빛', '물결', '나무'] as const; diff --git a/constants/server.ts b/constants/server.ts new file mode 100644 index 0000000..0c5ad80 --- /dev/null +++ b/constants/server.ts @@ -0,0 +1,10 @@ +export const HTTP_LIMITS = { windowMs: 60_000, api: 1200, sessions: 30, admin: 60, maxIps: 10_000, cleanupMs: 30_000 } as const; +export const HTTP_MESSAGES = { + rateLimit: '요청이 너무 많습니다. 잠시 후 다시 시도하세요.', + serverError: '요청을 처리하지 못했습니다. 잠시 후 다시 시도하세요.', + badRequest: '요청 형식을 확인하고 다시 시도하세요.', + notReady: '서버가 준비되지 않았습니다. 잠시 후 다시 시도하세요.', +} as const; +export const SERVER_LOG_LEVELS = ['fatal', 'error', 'warn', 'info', 'debug', 'trace', 'silent'] as const; +export const SAFE_ERROR_TYPES = ['Error', 'TypeError', 'RangeError', 'SyntaxError', 'MongoServerError', 'MongoNetworkError', 'MongoServerSelectionError', 'MongoOperationTimeoutError'] as const; +export const SAFE_ERROR_CODES = ['ECONNREFUSED', 'ETIMEDOUT', 'EADDRINUSE', 'EACCES', 'ENOTFOUND', 'INVALID_TRUST_PROXY', 'MISSING_MONGODB_URI'] as const; diff --git a/deploy/nginx.conf.example b/deploy/nginx.conf.example new file mode 100644 index 0000000..029018e --- /dev/null +++ b/deploy/nginx.conf.example @@ -0,0 +1,48 @@ +# Install in an nginx http-context include (e.g. sites-enabled). +# Replace game.example.com and certificate paths before enabling. +map $http_upgrade $dragon_ball_connection { + default upgrade; + '' close; +} +limit_req_zone $binary_remote_addr zone=dragon_ball_signal:10m rate=10r/s; + +server { + listen 80; + server_name game.example.com; + return 301 https://game.example.com$request_uri; +} + +server { + listen 443 ssl; + server_name game.example.com; + ssl_certificate /etc/letsencrypt/live/game.example.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/game.example.com/privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; + client_max_body_size 16k; + access_log off; + + location / { + proxy_pass http://127.0.0.1:2100; + proxy_http_version 1.1; + proxy_set_header Host $host; + # Internet-facing nginx on the app host; discard client-supplied forwarding. + proxy_set_header X-Forwarded-For $remote_addr; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Connection ""; + } + + location /socket.io/ { + limit_req zone=dragon_ball_signal burst=40 nodelay; + limit_req_status 429; + proxy_pass http://127.0.0.1:2100; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $remote_addr; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $dragon_ball_connection; + proxy_read_timeout 75s; + proxy_send_timeout 75s; + proxy_buffering off; + } +} diff --git a/docs/deployment.md b/docs/deployment.md new file mode 100644 index 0000000..830fcc1 --- /dev/null +++ b/docs/deployment.md @@ -0,0 +1,93 @@ +# Git clone + PM2 운영 + +Linux 서버, Node.js 24 이상, MongoDB, 같은 호스트의 HTTPS nginx를 기준으로 한다. 앱은 PM2 fork 프로세스 **1개**만 실행한다. 다른 서버/컨테이너의 프록시를 쓰면 바인딩 주소와 신뢰 프록시 주소를 해당 구성에 맞게 변경한다. + +## 최초 배포 + +배포할 코드와 새 파일을 먼저 커밋·push한다. 서버에서는 일반 서비스 계정으로 실행한다. + +```bash +git clone https://gitea.horoli.kr/horoli/ssessesse-ki-battle.git dragon_ball +cd dragon_ball +npm ci +cp .env.example .env +chmod 600 .env +``` + +`.env`에서 다음을 설정한다. + +- `MONGODB_URI`: 서버에서 연결 가능한 MongoDB 주소와 인증 정보. DB 포트를 인터넷에 공개하지 않는다. +- `MONGODB_DATABASE`: 초기화하려면 기존에 사용하지 않은 DB 이름을 지정한다. 예시의 `dragon_ball_production`도 이미 사용했다면 다른 이름을 지정한다. 기존 DB를 자동 삭제하지 않는다. +- `HOST=127.0.0.1`, `PORT=2100`, `TRUST_PROXY=loopback`: 같은 서버 nginx 전용. `TRUST_PROXY`는 쉼표로 구분한 신뢰 프록시 IP/CIDR 또는 `loopback`이며 직접 접속이면 비운다. +- `ADMIN_USER`, `ADMIN_PASSWORD`: 둘 다 지정하면 관리자 기능 활성화. 강한 전용 비밀번호를 사용한다. +- `STUN_URLS`, `TURN_URLS`, `TURN_SHARED_SECRET`: 친구전 ICE 설정. TURN 서버의 공유 비밀과 일치해야 하며 TURN 수신 포트·relay 포트 범위는 별도로 개방한다. +- `LOG_LEVEL=info`: 운영 로그 수준. + +```bash +npm run typecheck +npm run build +npm install --global pm2 +pm2 start ecosystem.config.cjs +pm2 save +pm2 startup +``` + +`pm2 startup`이 출력하는 명령을 실행한다. Node 버전 관리자를 변경하면 startup 설정도 갱신한다. PM2 설정은 실행 위치와 무관하게 저장소 루트를 사용하며 `.env`가 없으면 시작하지 않는다. 운영 환경에 이미 설정된 환경변수는 Node의 `.env` 값보다 우선하므로 충돌하는 값을 서비스 셸에 남기지 않는다. + +```bash +curl --fail http://127.0.0.1:2100/health +curl --fail http://127.0.0.1:2100/ready +pm2 logs dragon-ball --lines 50 +``` + +`/health`는 프로세스 응답, `/ready`는 DB 연결을 확인한다. + +## HTTPS 및 Socket.IO + +`deploy/nginx.conf.example`의 도메인과 인증서 경로를 교체해 nginx 설정에 추가한다. 해당 도메인의 인증서를 먼저 준비한다. 예시는 인증서 발급을 자동화하지 않는다. + +```bash +sudo nginx -t +sudo systemctl reload nginx +``` + +외부에서는 HTTPS 주소로 접속한다. `/signal`은 Socket.IO namespace이며 프록시 전송 경로는 `/socket.io/`다. 예시는 WebSocket 업그레이드와 polling을 함께 전달한다. 인터넷에 직접 노출된 nginx가 `X-Forwarded-For`를 실제 접속 IP로 덮어쓴다. CDN/추가 프록시 뒤에서는 원본 IP 검증 구성을 먼저 맞춘다. 앱의 2100 포트는 외부에 개방하지 않는다. + +앱 API의 IP별 요청 제한과 별도로 nginx는 Socket.IO 요청을 IP당 초당 10회, 순간 초과 40회까지 허용한다. 공유 IP 사용자가 많으면 실제 사용량을 보고 조정한다. TURN 트래픽은 nginx HTTP 프록시를 통과하지 않는다. + +앱은 IP별 1분 창에서 API 전체 1,200회, 세션 생성 30회, 관리자 HTML/API 합계 60회를 허용한다. 초과하면 HTTP 429와 `Retry-After`를 반환하며 DB 처리 전에 차단한다. 기준값은 `constants/server.ts`에서 관리한다. 제한 상태는 메모리에만 보관하고 재시작 시 초기화한다. 관리자 제한도 공유 IP 기준이므로 관리자가 같은 네트워크를 사용하면 합산된다. + +랭킹 개인 전적 캐시는 최대 1,000명·5분, 리더보드는 5초 동안 재사용하며 결과 저장 시 무효화한다. 동시 리더보드 요청은 하나의 계산을 공유한다. 캐시가 없거나 만료되면 저장된 경기 기록을 다시 읽으므로 대규모 장기 운영에서 영구 집계를 대신하는 구조는 아니다. 단일 프로세스만 같은 운영 DB에 쓰도록 유지한다. + +## 업데이트 + +단일 프로세스이므로 배포 중 접속이 중단되며 진행 중 랭킹 경기는 복원되지 않는다. 운영 폴더를 실행 중에 빌드하지 않도록 중지 후 갱신한다. + +```bash +pm2 stop dragon-ball +git pull --ff-only +npm ci +npm run typecheck +npm run build +pm2 restart ecosystem.config.cjs --update-env +curl --fail http://127.0.0.1:2100/ready +pm2 save +``` + +각 명령이 성공한 경우에만 다음 단계로 진행한다. 빌드 실패 시 재시작하지 말고 수정하거나 이전 커밋으로 복구한 후 다시 설치·빌드한다. `.env`는 Git에서 제외되어 유지된다. 데이터 백업은 MongoDB를 별도로 관리한다. + +## 로그 관리 및 운영 확인 + +```bash +pm2 status +pm2 logs dragon-ball --lines 100 +pm2 install pm2-logrotate +pm2 set pm2-logrotate:max_size 10M +pm2 set pm2-logrotate:retain 7 +``` + +로그 회전은 디스크가 무한히 늘어나지 않도록 운영 서버에서 설정한다. 예시 nginx는 access log를 끄지만 nginx 자체 error log에는 URL·IP가 기록될 수 있으므로 서버의 로그 접근권한과 보관 정책도 설정한다. + +배포 후 서로 다른 인터넷망에서 친구전 연결·재대전, 랭킹 매칭·승패 저장, 관리자 인증을 확인한다. 로컬 테스트 통과가 실제 TURN 경로 검증을 대신하지 않는다. + +참고: [PM2 설정](https://pm2.keymetrics.io/docs/usage/application-declaration/), [PM2 종료 처리](https://pm2.keymetrics.io/docs/usage/signals-clean-restart/), [nginx WebSocket 프록시](https://nginx.org/en/docs/http/websocket.html). diff --git a/ecosystem.config.cjs b/ecosystem.config.cjs new file mode 100644 index 0000000..5a09fac --- /dev/null +++ b/ecosystem.config.cjs @@ -0,0 +1,21 @@ +const path = require('node:path'); + +module.exports = { + apps: [{ + name: 'dragon-ball', + cwd: __dirname, + script: 'dist/server/src/server/index.js', + node_args: [`--env-file=${path.join(__dirname, '.env')}`], + exec_mode: 'fork', + instances: 1, + watch: false, + autorestart: true, + restart_delay: 3000, + min_uptime: '10s', + max_restarts: 10, + wait_ready: true, + listen_timeout: 30000, + kill_timeout: 15000, + env: { NODE_ENV: 'production' }, + }], +}; diff --git a/src/client/app-footer.css b/src/client/app-footer.css new file mode 100644 index 0000000..86ff6c7 --- /dev/null +++ b/src/client/app-footer.css @@ -0,0 +1,41 @@ +/* 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; + width: 100vw; + align-self: center; + flex-direction: row; + align-items: center; + justify-content: flex-end; + gap: 4px; + padding: 8px max(24px, env(safe-area-inset-right)) max(8px, env(safe-area-inset-bottom)) max(16px, env(safe-area-inset-left)); + background: transparent; + color: var(--muted); +} + +body.player-app .app-footer nav { display: flex; align-items: center; gap: 4px; } +body.player-app .app-footer :is(.footer-divider, 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 #connection::before { background: #9ca3af; } +body.player-app .app-footer #connection.connected { color: #15803d; } +body.player-app .app-footer #connection.connected::before { background: #15803d; } +body.player-app .app-footer #connection.disconnected { color: #dc2626; } +body.player-app .app-footer #connection.disconnected::before { background: #dc2626; } +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; } + +@media (max-width: 760px) { + body.player-app:is([data-screen=home], [data-screen=join]) .app > footer.app-footer { position: static; margin-top: auto; } +} diff --git a/src/client/app-footer.ts b/src/client/app-footer.ts new file mode 100644 index 0000000..1e4f33d --- /dev/null +++ b/src/client/app-footer.ts @@ -0,0 +1,23 @@ +import { FOOTER_LINKS } from '../../constants/footer.js'; + +type FooterDialog = typeof FOOTER_LINKS[number]['id']; + +export function createAppFooter(container: HTMLElement, dialogs: Record) { + container.classList.add('app-footer'); + container.innerHTML = `연결 중`; + const status = container.querySelector('#connection')!; + for (const { id } of FOOTER_LINKS) { + container.querySelector(`#${id}-open`)!.onclick = () => dialogs[id].showModal(); + dialogs[id].querySelectorAll('[data-close]').forEach(button => { + button.onclick = () => dialogs[id].close(); + }); + } + return { + setConnection(message: string, connected: boolean, disconnected = false) { + status.textContent = message; status.classList.toggle('connected', connected); + status.classList.toggle('disconnected', !connected && disconnected); + }, + openRules() { dialogs.rules.showModal(); }, + }; +} diff --git a/src/client/finish-cutscene.ts b/src/client/finish-cutscene.ts index 3f48202..12bba7f 100644 --- a/src/client/finish-cutscene.ts +++ b/src/client/finish-cutscene.ts @@ -3,6 +3,7 @@ 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'; +import { DEFAULT_GLOBAL_SETTINGS } from '../../constants/global-settings.js'; // Survives view replacement on reconnect, without persisting anything to game state. let presentation: { matchId: string; startedAt: number; dismissed: boolean } | undefined; @@ -11,6 +12,7 @@ export function createFinishCutscene(onDismiss: () => void, appearances?: [Fight let dialog: HTMLDialogElement | undefined, timer: ReturnType | undefined; let returnFocus: HTMLElement | undefined, previousBodyState: string | undefined; let activeMatch = '', resultAt = 0, artSignature = ''; + let timing: Record = { ...CUTSCENE_TIMING }; const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)'); function close(restoreFocus: boolean) { @@ -36,20 +38,18 @@ export function createFinishCutscene(onDismiss: () => void, appearances?: [Fight 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 complete = (reducedMotion.matches || elapsed >= timing.complete) && now >= resultAt; + const reactionEnds = dialog.dataset.attack === 'beam' ? timing.beamDissolve : timing.fallen; const stage = complete ? 'complete' : reducedMotion.matches || elapsed >= reactionEnds ? 'fallen' - : elapsed >= CUTSCENE_TIMING.fallen ? 'reaction' - : elapsed >= CUTSCENE_TIMING.impact ? 'impact' : 'attack'; + : elapsed >= timing.fallen ? 'reaction' + : elapsed >= timing.impact ? 'impact' : 'attack'; dialog.dataset.stage = stage; - const button = dialog.querySelector('.cutscene-result')!; - button.hidden = !complete; if (complete) { - if (document.activeElement === dialog) button.focus({ preventScroll: true }); + dismiss(); } 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 + const next = reducedMotion.matches ? resultAt : Math.max(resultAt, presentation.startedAt + timing.complete); + const boundary = !reducedMotion.matches && elapsed < timing.impact ? presentation.startedAt + timing.impact + : !reducedMotion.matches && elapsed < timing.fallen ? presentation.startedAt + timing.fallen : !reducedMotion.matches && elapsed < reactionEnds ? presentation.startedAt + reactionEnds : next; timer = setTimeout(advance, Math.max(0, boundary - now)); } @@ -75,6 +75,9 @@ export function createFinishCutscene(onDismiss: () => void, appearances?: [Fight return; } const now = performance.now(); + const speed = (state.settings ?? DEFAULT_GLOBAL_SETTINGS).finishCutsceneSpeed; + timing = { impact: CUTSCENE_TIMING.impact / speed, fallen: CUTSCENE_TIMING.fallen / speed, + beamDissolve: CUTSCENE_TIMING.beamDissolve / speed, complete: CUTSCENE_TIMING.complete / speed }; if (presentation?.matchId !== state.matchId) { presentation = { matchId: state.matchId, startedAt: now - Math.max(0, state.clockNow - reveal.revealedAt), dismissed: false }; } @@ -84,14 +87,14 @@ export function createFinishCutscene(onDismiss: () => void, appearances?: [Fight previousBodyState = document.body.dataset.cutscene; const name = (index: number) => index === state.selfIndex ? '나' : state.mode === 'bot' ? '봇' : '상대'; dialog = document.createElement('dialog'); + dialog.style.setProperty('--cutscene-time-scale', String(1 / speed)); 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)}`; + dialog.innerHTML = finisherArt(hit, reveal.actions[attacker], chosenAppearances); artSignature = signature; - dialog.querySelector('.cutscene-result')!.onclick = dismiss; dialog.addEventListener('cancel', event => { event.preventDefault(); dismiss(); }); document.body.append(dialog); document.body.dataset.cutscene = 'active'; diff --git a/src/client/friend-match.ts b/src/client/friend-match.ts index 4c638d0..0b550e4 100644 --- a/src/client/friend-match.ts +++ b/src/client/friend-match.ts @@ -1,4 +1,4 @@ -import { MATCH_COUNTDOWN_MS, RULES_VERSION } from '../../constants/gameplay.js'; +import { 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'; @@ -7,6 +7,7 @@ import type { MatchCheckpoint, PeerMessage, PublicCheckpoint } from '../shared/p import type { ActionId, Pair } from '../shared/types.js'; import type { GameClock } from './match-controller.js'; import type { PeerConnection } from './peer.js'; +import { matchIntroductionDuration, matchPreparationDuration } from '../shared/match-introduction.js'; export type GamePeer = Pick; interface FriendConfig { @@ -19,6 +20,7 @@ export function createFriendMatch(config: FriendConfig) { 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 pendingTurnId: number | undefined, pendingCommit: Extract | undefined; 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; @@ -29,7 +31,12 @@ export function createFriendMatch(config: FriendConfig) { function clear(name: string) { clock.clearTimeout(timers.get(name)); timers.delete(name); } function later(name: string, fn: () => void, ms: number) { clear(name); timers.set(name, clock.setTimeout(() => { timers.delete(name); if (!stopped) fn(); }, Math.max(0, ms))); } function clearAll() { for (const name of [...timers.keys()]) clear(name); } - function run(fn: () => unknown | Promise) { void Promise.resolve().then(fn).catch(() => { if (!stopped) abort('protocol_error'); }); } + function handleError() { + if (stopped) return; + if (!peer.connected) { void recover(); return; } + abort('protocol_error'); + } + function run(fn: () => unknown | Promise) { void Promise.resolve().then(fn).catch(handleError); } function send(type: PeerMessage['type'], fields: Record = {}) { peer.send({ protocolVersion: PROTOCOL_VERSION, rulesVersion: RULES_VERSION, matchId: config.matchId, connectionEpoch: peer.epoch, turnId: engine.view().turnId, type, ...fields } as PeerMessage); @@ -42,7 +49,7 @@ export function createFriendMatch(config: FriendConfig) { engine.abort(reason); config.changed(); config.finished(); stopped = true; peer.close(); leaveResolve?.(); } - function armProtocol() { later('protocol', () => abort('protocol_error'), PROTOCOL_TIMEOUT_MS); } + function armProtocol() { later('protocol', () => { void recover(); }, 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], @@ -90,6 +97,7 @@ export function createFriendMatch(config: FriendConfig) { else if (presentAt !== undefined) present(presentAt); } async function resolved(actions: Pair) { + clear('deadline'); 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]); @@ -97,20 +105,24 @@ export function createFriendMatch(config: FriendConfig) { send('turn:resolved', { digest: ownDigest }); agree(); } function selection() { + const earlyCommit = pendingCommit; pendingTurnId = undefined; pendingCommit = undefined; 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() }); + onPrivateState: value => { privateChoice = value; config.changed(); }, + onPhase: phase => { if (phase !== 'commit' || now() >= state.deadline) armProtocol(); } }); + if (earlyCommit) void exchange.receive(earlyCommit).catch(handleError); config.changed(); - later('deadline', () => run(() => exchange!.commit(engine.view().ownAction)), state.deadline - now()); + later('deadline', () => { armProtocol(); 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(); } + pendingTurnId = engine.view().turnId + 1; pendingCommit = undefined; + if (!begun) { begun = true; engine.start(startsAt - matchPreparationDuration(engine.view().settings)); confirmed = engine.checkpoint(); config.changed(); } later('start', () => { if (engine.beginTurn(startsAt)) selection(); }, startsAt - now()); } function startTurn(startsAt: number, resume = false) { @@ -118,7 +130,7 @@ export function createFriendMatch(config: FriendConfig) { send('turn:start', { startsAt, resume, turnId }); applyStart(startsAt, resume); } function maybeStart() { - if (localReady && peerReady && config.slot === 0 && !begun && !recovering) startTurn(now() + MATCH_COUNTDOWN_MS); + if (localReady && peerReady && config.slot === 0 && !begun && !recovering) startTurn(now() + matchPreparationDuration(engine.view().settings) + matchIntroductionDuration(engine.view().settings, false)); } function maybeResume() { if (!recovering || !resumedPeer || !resumedAck) return; @@ -129,9 +141,11 @@ export function createFriendMatch(config: FriendConfig) { 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; + // A confirmed result keeps its original reveal window when the peer leaves. + engine.presence(false); config.changed(); config.finished(); return; } - recovering = true; resumedPeer = resumedAck = false; clearAll(); exchange = undefined; + recovering = true; resumedPeer = resumedAck = false; clearAll(); exchange = undefined; pendingTurnId = undefined; pendingCommit = undefined; + engine.restore(confirmed); engine.presence(false); config.changed(); later('recovery', () => abort('connection_failed'), RECONNECT_GRACE_MS - (clock.now() - disconnectedAt)); try { @@ -165,13 +179,20 @@ export function createFriendMatch(config: FriendConfig) { } 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 (recovering) return; + // A peer may select before this browser's scheduled turn-start callback runs. + if (message.type === 'turn:commit' && message.turnId === pendingTurnId) { + if (message.slot === config.slot) throw new Error('INVALID_SLOT'); + if (pendingCommit && pendingCommit.digest !== message.digest) throw new Error('COMMITMENT_CHANGED'); + pendingCommit = message; return; + } + if (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 offMessage = peer.onMessage(message => { queue = queue.then(() => receive(message)).catch(handleError); }); const offState = peer.onState(state => { if (state === 'disconnected' || state === 'failed') { const disconnectedAt = clock.now(); void queue.then(() => recover(disconnectedAt)); } }); @@ -184,6 +205,7 @@ export function createFriendMatch(config: FriendConfig) { catch { abort('connection_failed'); } }, get recovering() { return recovering; }, + submitAction() { if (!recovering && !stopped) run(() => exchange!.commit(engine.view().ownAction)); }, requestRematch() { engine.requestRematch(config.slot); if (peer.connected) send('match:rematch'); config.changed(); }, snapshot: () => ({ confirmed: structuredClone(confirmed), epoch: peer.epoch, privateChoice }), async leave() { diff --git a/src/client/game.css b/src/client/game.css index e7a428f..b31f120 100644 --- a/src/client/game.css +++ b/src/client/game.css @@ -95,13 +95,10 @@ dialog.finish-cutscene::backdrop{background:#080a0a;backdrop-filter:none} .rock-fallen-art{overflow:visible} /* Stage changes reveal additional panels; previously revealed panels stay on the page. */ .finish-cutscene[data-stage=attack] .cutscene-impact,.finish-cutscene[data-stage=attack] .cutscene-fallen,.finish-cutscene[data-stage=impact] .cutscene-fallen{visibility:hidden} -.finish-cutscene[data-stage=attack] .cutscene-attack{animation:cutscene-enter .28s ease-out both} -.finish-cutscene[data-stage=impact] .cutscene-impact{animation:cutscene-enter .22s ease-out both} -.finish-cutscene[data-stage=fallen] .cutscene-fallen{animation:cutscene-enter .4s ease-out both} -.finish-cutscene[data-stage=impact] .cutscene-combatant[data-hit=true] .character{animation:cutscene-hit .35s ease-out both} -.finish-cutscene .cutscene-result{position:absolute;right:max(16px,env(safe-area-inset-right));bottom:max(14px,env(safe-area-inset-bottom));z-index:10;min-height:44px;padding:9px 17px;border:1px solid #d5cfbb;border-radius:3px;background:#141a18;color:#fff7e4;font-size:13px;line-height:1.2;box-shadow:2px 3px 0 #05070780} -.finish-cutscene .cutscene-result:hover{background:#303c35;border-color:#fff5db;transform:none} -.finish-cutscene .cutscene-result:focus-visible{outline:3px solid #fff6cd;outline-offset:4px} +.finish-cutscene[data-stage=attack] .cutscene-attack{animation:cutscene-enter calc(.28s * var(--cutscene-time-scale, 1)) ease-out both} +.finish-cutscene[data-stage=impact] .cutscene-impact{animation:cutscene-enter calc(.22s * var(--cutscene-time-scale, 1)) ease-out both} +.finish-cutscene[data-stage=fallen] .cutscene-fallen{animation:cutscene-enter calc(.4s * var(--cutscene-time-scale, 1)) ease-out both} +.finish-cutscene[data-stage=impact] .cutscene-combatant[data-hit=true] .character{animation:cutscene-hit calc(.35s * var(--cutscene-time-scale, 1)) ease-out both} @keyframes cutscene-enter{from{opacity:0;translate:0 14px}to{opacity:1;translate:0 0}} @keyframes cutscene-hit{from{transform:translate(-6px,-3px) rotate(-12deg)}to{transform:translate(-23px,-10px) rotate(-35deg)}} @media(max-width:520px){ @@ -125,7 +122,7 @@ dialog.finish-cutscene::backdrop{background:#080a0a;backdrop-filter:none} .finish-cutscene[data-attack=beam] .beam-impact-victim{position:absolute;right:2%;top:54%;width:var(--beam-caster-size);aspect-ratio:260/300;translate:0 -50%;z-index:2;visibility:hidden} .finish-cutscene[data-attack=beam] .beam-impact-victim[data-fighter="0"]{right:auto;left:2%} .finish-cutscene[data-attack=beam] .beam-impact-victim .character{transform:translate(-10px,-8px) rotate(-25deg);transform-origin:130px 150px} -.finish-cutscene[data-attack=beam][data-stage=impact] .beam-ray{animation:beam-cutscene-fire .3s ease-out both} +.finish-cutscene[data-attack=beam][data-stage=impact] .beam-ray{animation:beam-cutscene-fire calc(.3s * var(--cutscene-time-scale, 1)) ease-out both} .finish-cutscene[data-attack=beam] .beam-aftermath{background:#080a0a;animation:none;translate:none;opacity:1} .finish-cutscene[data-attack=beam] .beam-aftermath:after{opacity:.16} .finish-cutscene[data-attack=beam] .beam-reaction{position:absolute;inset:0 0 0 42%;overflow:hidden;clip-path:polygon(24% 0,100% 0,100% 100%,0 100%);background:#fcffff;visibility:hidden} @@ -145,10 +142,10 @@ dialog.finish-cutscene::backdrop{background:#080a0a;backdrop-filter:none} .finish-cutscene[data-attack=beam] .beam-fragments{opacity:.6;transform:translateX(0)} .finish-cutscene[data-attack=beam]:is([data-stage=reaction],[data-stage=fallen],[data-stage=complete]) .beam-reaction{visibility:visible} .finish-cutscene[data-attack=beam]:is([data-stage=fallen],[data-stage=complete]) .beam-dissolve{visibility:visible} -.finish-cutscene[data-attack=beam][data-stage=reaction] .beam-reaction-erosion{animation:beam-cutscene-erode .55s ease-out both} -.finish-cutscene[data-attack=beam][data-stage=reaction] .beam-reaction-light{animation:beam-cutscene-irradiate .55s ease-out both} -.finish-cutscene[data-attack=beam][data-stage=fallen] .beam-remnant{animation:beam-cutscene-fade .55s ease-out both} -.finish-cutscene[data-attack=beam][data-stage=fallen] .beam-fragments{animation:beam-cutscene-scatter .55s ease-out both} +.finish-cutscene[data-attack=beam][data-stage=reaction] .beam-reaction-erosion{animation:beam-cutscene-erode calc(.55s * var(--cutscene-time-scale, 1)) ease-out both} +.finish-cutscene[data-attack=beam][data-stage=reaction] .beam-reaction-light{animation:beam-cutscene-irradiate calc(.55s * var(--cutscene-time-scale, 1)) ease-out both} +.finish-cutscene[data-attack=beam][data-stage=fallen] .beam-remnant{animation:beam-cutscene-fade calc(.55s * var(--cutscene-time-scale, 1)) ease-out both} +.finish-cutscene[data-attack=beam][data-stage=fallen] .beam-fragments{animation:beam-cutscene-scatter calc(.55s * var(--cutscene-time-scale, 1)) ease-out both} @keyframes beam-cutscene-fire{from{opacity:.35;transform:scaleX(.15)}to{opacity:1;transform:scaleX(1)}} @keyframes beam-cutscene-erode{from{transform:translateX(22px)}to{transform:translateX(0)}} @keyframes beam-cutscene-irradiate{from{opacity:.2}to{opacity:.85}} diff --git a/src/client/global-settings.ts b/src/client/global-settings.ts new file mode 100644 index 0000000..be1184e --- /dev/null +++ b/src/client/global-settings.ts @@ -0,0 +1,26 @@ +import { DEFAULT_GLOBAL_SETTINGS, GLOBAL_SETTINGS_CACHE_KEY, GLOBAL_SETTINGS_REQUEST_TIMEOUT_MS } from '../../constants/global-settings.js'; +import { isGlobalSettings, type GlobalSettings } from '../shared/global-settings.js'; + +export function createGlobalSettingsCache(storage: Pick, send: typeof fetch = globalThis.fetch.bind(globalThis)) { + let current: GlobalSettings | undefined, pending: Promise | undefined; + try { const cached: unknown = JSON.parse(storage.getItem(GLOBAL_SETTINGS_CACHE_KEY) ?? 'null'); if (isGlobalSettings(cached)) current = cached; } catch { /* Storage can be unavailable. */ } + return { + refresh(): Promise { + if (pending) return pending; + pending = (async () => { + try { + const response = await send('/api/global-settings' + (current ? '?revision=' + current.revision : ''), { cache: 'no-store', signal: AbortSignal.timeout(GLOBAL_SETTINGS_REQUEST_TIMEOUT_MS) }); + if (!response.ok) throw new Error('SETTINGS_UNAVAILABLE'); + const result = await response.json(); + if (!result.ok) throw new Error('SETTINGS_UNAVAILABLE'); + if (isGlobalSettings(result.data.settings)) { + current = result.data.settings; + try { storage.setItem(GLOBAL_SETTINGS_CACHE_KEY, JSON.stringify(current)); } catch { /* Keep the in-memory copy. */ } + } else if (!current || result.data.revision !== current.revision) throw new Error('INVALID_SETTINGS'); + } catch { /* Recheck on the next load, focus or match; retain the last valid cache. */ } + return { ...(current ?? DEFAULT_GLOBAL_SETTINGS) }; + })().finally(() => { pending = undefined; }); + return pending; + }, + }; +} diff --git a/src/client/index.html b/src/client/index.html index e330256..f3c1142 100644 --- a/src/client/index.html +++ b/src/client/index.html @@ -17,9 +17,9 @@

-
연결 중
+
-

규칙

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

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

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

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

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

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

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

+

규칙

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

선택은 바꿀 수 없습니다. 봇 대전은 선택 즉시, 사용자 간 대전은 양쪽 선택이 끝나면 함께 공개됩니다.

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

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

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

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

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

개인정보·분석 안내

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

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

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

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

문의

diff --git a/src/client/lobby-features.css b/src/client/lobby-features.css index 7837807..504d5a9 100644 --- a/src/client/lobby-features.css +++ b/src/client/lobby-features.css @@ -40,6 +40,8 @@ body.player-app #appearance-dialog .color-condition { color: #242526; font-weigh .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; } +.match-introduction { min-height: calc(100svh - 80px); } +.rematch-status.opponent-left { color: #dc2626; } @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; } diff --git a/src/client/lobby-features.ts b/src/client/lobby-features.ts index e5bb86c..b2978a9 100644 --- a/src/client/lobby-features.ts +++ b/src/client/lobby-features.ts @@ -9,7 +9,7 @@ import { fighterArt } from './fighter-art.js'; interface LobbyOptions { api: GameApi; - onQueue(): void; + onQueue(message: string): void; onMatch(state: LocalMatchState): void; onHome(): void; onAppearance(appearance: FighterAppearance): void; @@ -29,7 +29,7 @@ export function createLobbyFeatures(options: LobbyOptions) { tools.innerHTML = ''; actions.after(tools); const queue = document.createElement('section'); queue.id = 'ranked-queue'; queue.className = 'waiting ranked-queue'; queue.hidden = true; - queue.innerHTML = '

대전 상대 찾는 중

'; + queue.innerHTML = '

랭킹대전 매칭 중…

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

캐릭터·장비

피부

머리

도복 색상

장비

안경

'; @@ -169,12 +169,16 @@ export function createLobbyFeatures(options: LobbyOptions) { content.append(table); } catch (error) { content.textContent = error instanceof Error ? error.message : '랭킹을 불러오지 못했습니다.'; } }; + function showQueue(message: string) { + select(queue, 'h1').textContent = message; + options.onQueue(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(); + if (status.status === 'queued') showQueue('랭킹대전 매칭 중…'); else if (status.match) options.onMatch(status.match); else options.onHome(); } @@ -195,8 +199,9 @@ export function createLobbyFeatures(options: LobbyOptions) { if (busy || active) return; busy = true; rankedStart.disabled = true; generation++; options.onAvailabilityChange(); + showQueue('랭킹대전 매칭 중…'); try { consume(await api.request('/api/ranked/queue', 'POST', {})); schedule(); } - catch (error) { options.onError(error); } + catch (error) { options.onHome(); options.onError(error); } finally { busy = false; options.onAvailabilityChange(); } } async function leave() { diff --git a/src/client/main.ts b/src/client/main.ts index 53f033e..edf65e7 100644 --- a/src/client/main.ts +++ b/src/client/main.ts @@ -15,13 +15,24 @@ 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 { createGlobalSettingsCache } from './global-settings.js'; +import { createAppFooter } from './app-footer.js'; import './game.css'; import './match.css'; import './home.css'; import './ui-theme.css'; import './lobby-features.css'; +import './app-footer.css'; const el = (id: string) => document.getElementById(id)! as T; +const footer = createAppFooter(el('app-footer'), { + rules: el('rules-dialog'), privacy: el('privacy-dialog'), contact: el('contact-dialog'), +}); +const globalSettings = createGlobalSettingsCache({ + getItem: key => localStorage.getItem(key), setItem: (key, value) => localStorage.setItem(key, value), +}); +window.addEventListener('focus', () => { void globalSettings.refresh(); }); +document.addEventListener('visibilitychange', () => { if (!document.hidden) void globalSettings.refresh(); }); document.title = GAME_NAME; const homeFighters = el('home-fighters'); homeFighters.innerHTML = '
' + fighterArt(0) + '
' + fighterArt(0, false, HOME_TELEPORT_APPEARANCE) + '
' + fighterArt(1) + '
'; @@ -46,10 +57,11 @@ const analytics = createClientAnalytics({ let reporter: ReturnType; let controller: MatchController | undefined, view: ReturnType | undefined; let currentMatch: LocalMatchState | undefined, currentRoom: PeerRoom | undefined, currentScreen = ''; +let matchReceivedAt = 0; let waitingSocket: Socket | undefined, roomPoll: ReturnType | undefined; let startingMatch: string | undefined, startedEvent: string | undefined, finishedEvent: string | undefined; function notice(message = '') { el('notice').textContent = message; } -function connection(message: string, connected: boolean) { el('connection').textContent = message; el('connection').classList.toggle('connected', connected); } +const connection = footer.setConnection; function controls() { ['bot-start', 'room-create', 'join-open'].forEach(id => { el(id).disabled = !ready || pending || !!lobby?.busy || !!lobby?.active; }); lobby?.setReady(ready && !pending); @@ -94,10 +106,11 @@ function showMatch(state: LocalMatchState) { state = { ...state, rematch: [...currentRoom.rematch], opponentLeft: currentRoom.status === 'closed' }; } currentMatch = state; + matchReceivedAt = Date.now(); 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()); + () => { void perform(leave); }, () => setScreen('result'), footer.openRules); view.update(state); if (state.finished?.saved && !state.ranked) stats(state.finished.stats); if (state.saveStatus === 'failed') el('reconnect').hidden = false; @@ -105,9 +118,10 @@ function showMatch(state: LocalMatchState) { connection('대전 종료', true); if (finishedEvent !== state.matchId && state.finished) { finishedEvent = state.matchId; + if (!state.ranked && state.mode === 'friend' && currentRoom?.status === 'paired') watchRematch(currentRoom); 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)); + } else connection(state.mode === 'friend' && !state.opponentPresent ? '연결 복구 중' : Number.isFinite(state.startsAt) ? '대전 중' : '연결 중', state.opponentPresent && Number.isFinite(state.startsAt), state.mode === 'friend' && !state.opponentPresent); } async function launch(mode: Mode, room?: PeerRoom, existing?: ActiveMatch) { const matchId = existing?.matchId ?? room?.currentMatchId ?? crypto.randomUUID(); @@ -115,7 +129,7 @@ async function launch(mode: Mode, room?: PeerRoom, existing?: ActiveMatch) { startingMatch = matchId; stopWaiting(); let active = existing; try { - const session = await api.sync(); + const [session] = await Promise.all([api.sync(), globalSettings.refresh()]); if (!active) { if (session.activeMatch) { if (session.activeMatch.matchId !== matchId) throw new ApiClientError(409, 'MATCH_PENDING', '이전 대전 기록을 저장한 뒤 다시 시도하세요.'); @@ -159,7 +173,7 @@ async function acceptRoom(room: PeerRoom) { 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.on('disconnect', () => { connection('연결 끊김', false, true); el('reconnect').hidden = false; }); waitingSocket.connect(); } async function rematch() { @@ -167,23 +181,48 @@ async function rematch() { if (currentMatch.mode === 'bot') { storage.removeItem(GAME_CHECKPOINT_KEY); await launch('bot'); return; } if (!currentRoom) return; const room = currentRoom; - const next = await api.request('/api/rooms/' + room.id + '/rematch', 'POST', { round: room.round, requestId: crypto.randomUUID() }); + let next: PeerRoom; + try { next = await api.request('/api/rooms/' + room.id + '/rematch', 'POST', { round: room.round, requestId: crypto.randomUUID() }); } + catch (error) { + if (error instanceof ApiClientError && [400, 409, 404, 410].includes(error.status)) { + let updated: PeerRoom; + try { updated = await api.request('/api/rooms/' + room.id); } + catch (readError) { + if (!(readError instanceof ApiClientError) || ![404, 410].includes(readError.status)) throw readError; + updated = { ...room, status: 'closed' }; + } + if (updated.status === 'closed') { rememberRoom(updated); refreshRoomMatch(); return; } + } + throw error; + } rememberRoom(next); - if (currentMatch) showMatch(currentMatch); + refreshRoomMatch(); if (next.round > room.round) { storage.removeItem(GAME_CHECKPOINT_KEY); await launch('friend', next); return; } watchRematch(room); } +function refreshRoomMatch() { + if (currentMatch) showMatch({ ...currentMatch, clockNow: currentMatch.clockNow + Date.now() - matchReceivedAt }); +} function watchRematch(room: PeerRoom) { clearTimeout(roomPoll); const poll = async () => { if (currentRoom?.id !== room.id) return; try { const updated = await api.request('/api/rooms/' + room.id); + if (currentRoom?.id !== room.id) return; rememberRoom(updated); - if (currentMatch) showMatch(currentMatch); + refreshRoomMatch(); 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; } + } catch (error) { + if (currentRoom?.id !== room.id) return; + if (error instanceof ApiClientError && [404, 410].includes(error.status)) { + rememberRoom({ ...room, status: 'closed' }); + refreshRoomMatch(); + return; + } + errorNotice(error); if (error instanceof ApiClientError && error.status < 500) return; + } roomPoll = setTimeout(() => { void poll(); }, ROOM_POLL_MS); }; roomPoll = setTimeout(() => { void poll(); }, ROOM_POLL_MS); @@ -201,7 +240,7 @@ async function boot() { try { let token: string | undefined; try { token = localStorage.getItem(GUEST_TOKEN_KEY) ?? undefined; } catch { storageAvailable = false; } - const session = await api.ensureSession(token); + const [session] = await Promise.all([api.ensureSession(token), globalSettings.refresh()]); 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; @@ -272,13 +311,11 @@ async function copy(link: boolean) { } el('copy-code').onclick = () => { void copy(false); }; el('copy-link').onclick = () => { void copy(true); }; el('rules-list').innerHTML = ACTION_IDS.map(id => '
' + ACTIONS[id].name + '기 ' + ACTIONS[id].cost + ' 소모 · ' + ACTIONS[id].effect + '
').join(''); -for (const name of ['rules', 'privacy', 'contact']) el(name + '-open').onclick = () => el(name + '-dialog').showModal(); -document.querySelectorAll('[data-close]').forEach(b => { b.onclick = () => b.closest('dialog')!.close(); }); const invitation = new URLSearchParams(location.search).get('room'); if (invitation && /^[A-Z0-9]{6}$/i.test(invitation)) el('room-code').value = invitation.toUpperCase(); lobby = createLobbyFeatures({ api, - onQueue: () => { setScreen('ranked-queue'); connection('상대 찾는 중', true); }, + onQueue: message => { setScreen('ranked-queue'); connection(message, true); }, onMatch: state => showMatch(state), onHome: () => { setScreen('home'); connection('접속됨', true); notice(); el('reconnect').hidden = true; }, onAppearance: appearance => { diff --git a/src/client/match-controller.ts b/src/client/match-controller.ts index d2d12f8..ebc5dab 100644 --- a/src/client/match-controller.ts +++ b/src/client/match-controller.ts @@ -44,7 +44,7 @@ export function createMatchController(config: ControllerConfig) { 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()]); + if (state.phase === 'selecting' && (state.ownAction || 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; @@ -69,7 +69,12 @@ export function createMatchController(config: ControllerConfig) { 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 submitAction(action: ActionId) { + if (friend?.recovering) return false; + const accepted = engine.lockAction(action); + if (accepted) { if (friend) { publish(); friend.submitAction(); } else tick(); } + return accepted; + }, async leave() { if (friend) { await friend.leave(); await record(); return; } clock.clearTimeout(timer); diff --git a/src/client/match-view.ts b/src/client/match-view.ts index b102f5e..0fdec07 100644 --- a/src/client/match-view.ts +++ b/src/client/match-view.ts @@ -1,10 +1,11 @@ -import { ACTIONS, ACTION_IDS, MATCH_COUNTDOWN_MS, TURN_DURATION_MS } from '../../constants/gameplay.js'; +import { ACTIONS, ACTION_IDS, MATCH_COUNTDOWN_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'; +import { matchIntroductionMessage } from '../shared/match-introduction.js'; const fighter = (i: number, appearance?: FighterAppearance) => `
${fighterArt(i, false, appearance)}
${ashPileArt()}
@@ -12,7 +13,7 @@ const fighter = (i: number, appearance?: FighterAppearance) => `

메뉴
+ container.innerHTML = `

메뉴
${fighter(0, appearances?.[0])}
${fighter(1, appearances?.[1])}
${actionIcon('charge')}
${ACTION_IDS.map(id => ``).join('')}
@@ -48,31 +49,39 @@ export function createMatchView(container: HTMLElement, onAction: (action: Actio function tick() { if (!current) return; const now = Date.now() + offset, s = current; - const revealing = !!s.reveal && now < s.reveal.revealEndsAt; + const introduction = matchIntroductionMessage(s, now); + el('.match-introduction').hidden = !introduction; + el('.match-introduction h1').textContent = introduction ?? ''; + arena.hidden = !!introduction; + const revealing = resultReported !== s.matchId && !!s.reveal && now < s.reveal.revealEndsAt; const resultVisible = s.phase === 'finished' && !revealing; + if (resultVisible && resultReported !== s.matchId) renderer.reset(); + const recovering = !resultVisible && !s.opponentPresent; 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-top').hidden = resultVisible || !!introduction; + el('.player-controls').hidden = finishing || resultVisible || !!introduction; + el('.timing').hidden = finishing || resultVisible || !!introduction; 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} 턴`; + el('.turn-heading').textContent = s.phase === 'countdown' ? s.ranked ? '랭킹대전 준비 중…' : '대전 준비' : 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('.duel-cue').hidden = revealing || resultVisible || recovering; 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-label').textContent = s.phase === 'selecting' && !revealing && !recovering ? `${(remaining / 1000).toFixed(1)}초` : ''; + el('.time-track').hidden = recovering; + const progress = s.phase === 'countdown' ? remaining / MATCH_COUNTDOWN_MS : s.phase === 'selecting' ? remaining / (s.deadline - s.startsAt) : 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); + el('.timing').dataset.urgent = String(!recovering && 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; @@ -95,6 +104,7 @@ export function createMatchView(container: HTMLElement, onAction: (action: Actio if (resultVisible && s.finished) { if (resultReported !== s.matchId) { resultReported = s.matchId; onResult(); } const f = s.finished; + el('.finish-reason').hidden = f.reason === 'hit'; const outcome = s.aborted ? 'aborted' : f.winnerIndex === null ? 'draw' : f.winnerIndex === s.selfIndex ? 'win' : 'loss'; container.dataset.outcome = outcome; arena.dataset.result = outcome; @@ -105,7 +115,9 @@ export function createMatchView(container: HTMLElement, onAction: (action: Actio el('.result-stats').textContent = f.saved ? `현재 연승 ${stats.current} · 최고 연승 ${stats.best}` : ''; el('.save-status').textContent = s.saveStatus === 'failed' ? '기록을 저장하지 못했습니다. 다시 시도하세요.' : f.saved ? '' : '기록 저장 중'; el('.rematch').disabled = !f.saved || (!s.ranked && (!!s.rematch[s.selfIndex] || s.opponentLeft)); - el('.rematch-status').textContent = s.ranked ? '' : s.opponentLeft ? '상대가 나갔습니다.' : s.rematch[s.selfIndex] ? '상대의 재대전 동의 대기' : s.rematch[s.selfIndex === 0 ? 1 : 0] ? '상대가 재대전을 요청했습니다.' : ''; + el('.rematch-status').textContent = s.ranked ? '' : s.opponentLeft ? '상대방이 나갔습니다' : s.rematch[s.selfIndex] ? '상대의 재대전 동의 대기' : s.rematch[s.selfIndex === 0 ? 1 : 0] ? '상대가 재대전을 요청했습니다.' : ''; + el('.rematch-status').setAttribute('role', s.opponentLeft ? 'alert' : 'status'); + el('.rematch-status').classList.toggle('opponent-left', s.opponentLeft); } else { delete arena.dataset.result; if (arena.dataset.outcome) container.dataset.outcome = arena.dataset.outcome; @@ -116,7 +128,10 @@ export function createMatchView(container: HTMLElement, onAction: (action: Actio 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 (lastId !== state.matchId) { + cutscene.reset(); renderer.reset(); lastId = state.matchId; busy = false; submission++; + el('.match-introduction .queue-fighter').innerHTML = fighterArt(state.selfIndex, false, (state.appearances ?? appearances)?.[state.selfIndex]); + } if (current && current.turnId !== state.turnId) { busy = false; submission++; } current = state; offset = state.clockNow - Date.now(); container.dataset.self = String(state.selfIndex); @@ -133,8 +148,11 @@ export function createMatchView(container: HTMLElement, onAction: (action: Actio 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(); + // Rematch/presence updates must never restart a match's completed presentation. + if (resultReported !== state.matchId) { + if (state.reveal && !state.aborted && (!state.finished || state.finished.reason === 'hit')) renderer.play(state.reveal, Date.now() + offset); + else renderer.reset(); + } tick(); cutscene.update(state); }, destroy() { submission++; clearInterval(interval); cutscene.destroy(); renderer.destroy(); container.innerHTML = ''; current = undefined; }, diff --git a/src/client/ui-theme.css b/src/client/ui-theme.css index c454dab..c4eb2ef 100644 --- a/src/client/ui-theme.css +++ b/src/client/ui-theme.css @@ -108,37 +108,6 @@ body.player-app #match .chosen-mark { background: var(--ink); border-color: #fff 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); @@ -153,9 +122,7 @@ 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; } } diff --git a/src/server/admin.ts b/src/server/admin.ts index 47decc6..a6296dc 100644 --- a/src/server/admin.ts +++ b/src/server/admin.ts @@ -5,7 +5,9 @@ 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) { +import type { GlobalSettingsStore } from './global-settings.js'; +import { gameRoute } from './game-api.js'; +export function registerAdmin(app: FastifyInstance, store: StatsStore, analytics: Analytics, clock: Clock, credentials: { user?: string; password?: string }, directory: string, settings?: GlobalSettingsStore) { const digest = (value: string) => createHash('sha256').update(value).digest(); const authorized = (header: string | undefined) => { if (!credentials.user || !credentials.password) return false; @@ -19,6 +21,10 @@ export function registerAdmin(app: FastifyInstance, store: StatsStore, analytics 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)); + if (settings) { + gameRoute(admin, 'GET', '/api/admin/global-settings', () => settings.get()); + gameRoute(admin, 'POST', '/api/admin/global-settings', request => settings.update(request.body)); + } admin.get('/api/admin/analytics/report', async (request, reply) => { let filters; try { filters = validateFilters(request.query as Record, new Date(clock.now())); } diff --git a/src/server/create-server.ts b/src/server/create-server.ts index 0f78da0..5255d43 100644 --- a/src/server/create-server.ts +++ b/src/server/create-server.ts @@ -14,15 +14,26 @@ import { registerAnalyticsApi, registerGameApi, registerRoomApi } from './game-a 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 } +import { createGlobalSettingsStore } from './global-settings.js'; +import { gameRoute } from './game-api.js'; +import { createHttpOptions, registerHttpOperations, logSafeError } from './http-operations.js'; +export interface ServerOptions { mongoUri: string; databaseName: string; clientDirectory: string; clock?: Clock; adminUser?: string; adminPassword?: string; environment?: 'production' | 'test'; rtc?: RtcServerOptions; trustProxy?: false | string[]; logLevel?: string } export async function createGameServer(options: ServerOptions): Promise { const store = await createStatsStore(options); - const app = Fastify({ logger: false, bodyLimit: 16384 }); + const app = Fastify(createHttpOptions(options)); const io = new Server(app.server, { maxHttpBufferSize: 16384 }); const clock = options.clock ?? systemClock; + registerHttpOperations(app, { now: () => clock.now(), ready: () => store.db.command({ ping: 1 }, { timeoutMS: 3000 }) }); + const settings = createGlobalSettingsStore(store.db); + await settings.initialize(); + gameRoute(app, 'GET', '/api/global-settings', async request => { + const current = await settings.get(); + return Number((request.query as { revision?: string }).revision) === current.revision + ? { revision: current.revision } : { revision: current.revision, settings: current }; + }); 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 ranked = await createRankedService(store.db, clock, options.environment ?? 'production', () => logSafeError(app.log, 'ranked_background', undefined)); const signaling = registerSignaling(io, gameSessions, peerRooms, options.rtc, () => clock.now()); registerGameApi(app, gameSessions, store, ranked); registerRoomApi(app, gameSessions, peerRooms, signaling, ranked); @@ -43,7 +54,7 @@ export async function createGameServer(options: ServerOptions): Promise { await ranked.close(); await analytics.close(); diff --git a/src/server/game-api.ts b/src/server/game-api.ts index 93743f5..bdf69ba 100644 --- a/src/server/game-api.ts +++ b/src/server/game-api.ts @@ -9,6 +9,7 @@ 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'; +import { logSafeError } from './http-operations.js'; export function gameAuth(request: FastifyRequest): GameSessionAuth { const token = request.headers.authorization?.match(/^Bearer ([A-Za-z0-9_-]{43})$/)?.[1]; @@ -23,6 +24,7 @@ export function gameRoute(app: FastifyInstance, method: 'GET' | 'POST', url: str 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; + if (status >= 500) logSafeError(request.log, 'http_request', error, request); return reply.code(status).send({ ok: false, code, message: GAME_API_MESSAGES[code] ?? '요청을 확인하고 다시 시도하세요.' }); } } }); diff --git a/src/server/game-session-store.ts b/src/server/game-session-store.ts index 2967e66..99af2fe 100644 --- a/src/server/game-session-store.ts +++ b/src/server/game-session-store.ts @@ -6,6 +6,8 @@ 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'; +import { createGlobalSettingsStore } from './global-settings.js'; +import { DEFAULT_GLOBAL_SETTINGS } from '../../constants/global-settings.js'; export class ApiError extends Error { constructor(public statusCode: number, public code: string) { super(code); } @@ -71,16 +73,18 @@ export async function createGameSessionStore(db: Db, now: () => number = Date.no const profile = before.playerId ? await db.collection('player_profiles').findOne({ _id: before.playerId }) : null; const ownAppearance = normalizeAppearance(profile?.appearance); let appearances: Pair = [ownAppearance, { characterId: ownAppearance.characterId === 0 ? 1 : 0, colorId: 'white' }]; + let settings = request.mode === 'bot' ? await createGlobalSettingsStore(db).get() : { ...DEFAULT_GLOBAL_SETTINGS }; if (request.mode === 'friend') { if (!validId(request.roomId)) throw new ApiError(400, 'INVALID_ROOM'); const room = await db.collection('rooms').findOne({ _id: request.roomId, currentMatchId: request.matchId, status: 'paired', expiresAt: { $gt: new Date(now()) }, $or: [{ hostGameSessionId: auth.gameSessionId }, { guestGameSessionId: auth.gameSessionId }] }); if (!room) throw new ApiError(404, 'ROOM_NOT_FOUND'); + settings = room.settings ?? { ...DEFAULT_GLOBAL_SETTINGS }; 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 }; + ...(request.roomId ? { roomId: request.roomId } : {}), appearances, settings, 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' }); diff --git a/src/server/global-settings.ts b/src/server/global-settings.ts new file mode 100644 index 0000000..3a29d0d --- /dev/null +++ b/src/server/global-settings.ts @@ -0,0 +1,35 @@ +import type { Db } from 'mongodb'; +import { DEFAULT_GLOBAL_SETTINGS } from '../../constants/global-settings.js'; +import { isGlobalSettings, type GlobalSettings } from '../shared/global-settings.js'; +import { ApiError } from './game-session-store.js'; + +export function createGlobalSettingsStore(db: Db) { + const collection = db.collection('global_settings'); + async function get(): Promise { + const doc = await collection.findOne({ _id: 'global' }); + if (!doc) return { ...DEFAULT_GLOBAL_SETTINGS }; + const { _id, ...stored } = doc; + const settings = { ...DEFAULT_GLOBAL_SETTINGS, ...stored }; + if (!isGlobalSettings(settings)) throw new Error('INVALID_GLOBAL_SETTINGS'); + return settings; + } + return { + async initialize() { + await collection.updateOne({ _id: 'global' }, { $setOnInsert: DEFAULT_GLOBAL_SETTINGS }, { upsert: true }); + for (const key of ['matchingDelayMs', 'matchSuccessDelayMs', 'matchPreparationDelayMs'] as const) { + await collection.updateOne({ _id: 'global', [key]: { $exists: false } }, { $set: { [key]: DEFAULT_GLOBAL_SETTINGS[key] }, $inc: { revision: 1 } }); + } + }, + get, + async update(input: unknown) { + if (!isGlobalSettings(input) || Object.keys(input).some(key => !Object.hasOwn(DEFAULT_GLOBAL_SETTINGS, key))) throw new ApiError(400, 'INVALID_SETTINGS'); + const doc = await collection.findOneAndUpdate({ _id: 'global', revision: input.revision }, { + $set: { turnDurationMs: input.turnDurationMs, finishCutsceneSpeed: input.finishCutsceneSpeed, matchingDelayMs: input.matchingDelayMs, matchSuccessDelayMs: input.matchSuccessDelayMs, matchPreparationDelayMs: input.matchPreparationDelayMs }, $inc: { revision: 1 }, + }, { returnDocument: 'after' }); + if (!doc) throw new ApiError(409, 'SETTINGS_REVISION_CONFLICT'); + const { _id, ...settings } = doc; + return settings; + }, + }; +} +export type GlobalSettingsStore = ReturnType; diff --git a/src/server/http-operations.ts b/src/server/http-operations.ts new file mode 100644 index 0000000..d0a390c --- /dev/null +++ b/src/server/http-operations.ts @@ -0,0 +1,96 @@ +import { isIP } from 'node:net'; +import { LogController } from 'fastify'; +import type { FastifyBaseLogger, FastifyInstance, FastifyRequest, FastifyServerOptions } from 'fastify'; +import { HTTP_LIMITS, HTTP_MESSAGES, SERVER_LOG_LEVELS, SAFE_ERROR_TYPES, SAFE_ERROR_CODES } from '../../constants/server.js'; + +// Only explicit addresses/networks are accepted; never trust arbitrary forwarded headers. +export function parseTrustProxy(value?: string): false | string[] { + if (!value?.trim()) return false; + const entries = value.split(',').map(item => item.trim()); + for (const entry of entries) { + if (entry === 'loopback') continue; + const [address, prefix, extra] = entry.split('/'); + const family = isIP(address); + if (!family || extra !== undefined || (prefix !== undefined && (!/^\d+$/.test(prefix) || Number(prefix) < 1 || Number(prefix) > (family === 4 ? 32 : 128)))) { + throw new Error('INVALID_TRUST_PROXY'); + } + } + return entries; +} + +export function createHttpOptions(options: { environment?: 'production' | 'test'; trustProxy?: false | string[]; logLevel?: string } = {}): FastifyServerOptions { + const level = SERVER_LOG_LEVELS.find(level => level === options.logLevel) ?? 'info'; + return { + bodyLimit: 16384, + trustProxy: options.trustProxy ? parseTrustProxy(options.trustProxy.join(',')) : false, + requestIdHeader: false, + logController: new LogController({ disableRequestLogging: true }), + logger: options.environment === 'test' ? false : { + level, + serializers: { + req: request => ({ method: request.method, route: request.routeOptions?.url }), + res: reply => ({ statusCode: reply.statusCode }), + err: () => ({ type: 'UnexpectedError', message: 'Internal failure', stack: '' }), + }, + }, + }; +} + +type ErrorOperation = 'http_request' | 'readiness' | 'startup' | 'shutdown' | 'ranked_background'; +// Deliberately exclude error message, stack, cause, arbitrary name/code and request input. +export function logSafeError(logger: Pick, operation: ErrorOperation, error: unknown, request?: FastifyRequest) { + const name = error instanceof Error ? error.name : undefined; + const code = error && typeof error === 'object' && 'code' in error ? error.code : undefined; + const errorType = SAFE_ERROR_TYPES.find(type => type === name) ?? 'Unknown'; + const errorCode = SAFE_ERROR_CODES.find(value => value === code) ?? (errorType === 'MongoServerError' && Number.isSafeInteger(code) ? code : undefined); + logger.error({ event: 'server_error', operation, errorType, ...(errorCode !== undefined ? { errorCode } : {}), + ...(request ? { requestId: request.id, method: request.method, route: request.routeOptions.url ?? 'unmatched' } : {}) }, 'Server operation failed'); +} + +export function registerHttpOperations(app: FastifyInstance, options: { now?: () => number; maxIps?: number; ready?: () => Promise } = {}) { + const now = options.now ?? Date.now; + const maxIps = options.maxIps ?? HTTP_LIMITS.maxIps; + const windows = new Map(); + let nextCleanup = 0; + app.addHook('onRequest', async (request, reply) => { + const path = request.routeOptions.url ?? request.url.split('?')[0]; + const admin = path === '/admin' || path.startsWith('/admin/') || path === '/api/admin' || path.startsWith('/api/admin/'); + const api = path === '/api' || path.startsWith('/api/'); + if (!admin && !api) return; + const time = now(); + if (time >= nextCleanup) { + for (const [ip, window] of windows) if (window.expires <= time) windows.delete(ip); + nextCleanup = time + HTTP_LIMITS.cleanupMs; + } + const reject = (expires: number) => reply.header('Cache-Control', 'no-store').header('Retry-After', Math.max(1, Math.ceil((expires - time) / 1000))) + .code(429).send({ ok: false, code: 'RATE_LIMITED', message: HTTP_MESSAGES.rateLimit }); + let window = windows.get(request.ip); + if (window && window.expires <= time) { windows.delete(request.ip); window = undefined; } + if (!window) { + // Do not evict active counters: rotating IPs must not reset another client's limit. + if (windows.size >= maxIps) return reject(time + HTTP_LIMITS.windowMs); + window = { expires: time + HTTP_LIMITS.windowMs, api: 0, sessions: 0, admin: 0 }; + windows.set(request.ip, window); + } + const sessions = request.method === 'POST' && path === '/api/game-sessions'; + if ((api && window.api >= HTTP_LIMITS.api) || (admin && window.admin >= HTTP_LIMITS.admin) || (sessions && window.sessions >= HTTP_LIMITS.sessions)) return reject(window.expires); + if (api) window.api++; + if (admin) window.admin++; + if (sessions) window.sessions++; + }); + app.addHook('onClose', async () => { windows.clear(); }); + app.setErrorHandler((error, request, reply) => { + const statusCode = error && typeof error === 'object' && 'statusCode' in error ? error.statusCode : undefined; + const status = typeof statusCode === 'number' && statusCode >= 400 && statusCode < 500 ? statusCode : 500; + if (status === 500) logSafeError(request.log, 'http_request', error, request); + return reply.header('Cache-Control', 'no-store').code(status).send({ ok: false, code: status === 500 ? 'SERVER_ERROR' : 'BAD_REQUEST', message: status === 500 ? HTTP_MESSAGES.serverError : HTTP_MESSAGES.badRequest }); + }); + if (options.ready) app.get('/ready', async (request, reply) => { + reply.header('Cache-Control', 'no-store'); + try { await options.ready!(); return { status: 'ok' }; } + catch (error) { + logSafeError(request.log, 'readiness', error, request); + return reply.code(503).send({ status: 'unavailable', message: HTTP_MESSAGES.notReady }); + } + }); +} diff --git a/src/server/index.ts b/src/server/index.ts index 06277f1..bd69ca2 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -1,10 +1,26 @@ 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 환경 변수가 필요합니다.'); +import { logSafeError, parseTrustProxy } from './http-operations.js'; +const { MONGODB_URI, MONGODB_DATABASE = 'dragon_ball', HOST = '127.0.0.1', PORT = '2100', ADMIN_USER, ADMIN_PASSWORD } = process.env; 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(); }); +const startupLogger = { error: (data: unknown) => console.error(JSON.stringify({ level: 50, time: Date.now(), ...data as object })) }; +let app: Awaited> | undefined; +try { + if (!MONGODB_URI) throw Object.assign(new Error(), { code: 'MISSING_MONGODB_URI' }); + app = await createGameServer({ mongoUri: MONGODB_URI, databaseName: MONGODB_DATABASE, clientDirectory: resolve('dist/client'), adminUser: ADMIN_USER, adminPassword: ADMIN_PASSWORD, + trustProxy: parseTrustProxy(process.env.TRUST_PROXY), logLevel: process.env.LOG_LEVEL, + rtc: { stunUrls: urls(process.env.STUN_URLS), turnUrls: urls(process.env.TURN_URLS), turnSecret: process.env.TURN_SHARED_SECRET } }); + let closing = false; + for (const signal of ['SIGINT', 'SIGTERM'] as const) process.once(signal, () => { + if (closing) return; + closing = true; + void app!.close().catch(error => { logSafeError(app!.log, 'shutdown', error); process.exitCode = 1; }); + }); + await app.listen({ host: HOST, port: Number(PORT) }); + process.send?.('ready'); +} catch (error) { + logSafeError(startupLogger, 'startup', error); + if (app) await app.close().catch(closeError => logSafeError(startupLogger, 'shutdown', closeError)); + // Initialization may have allocated a DB pool before it failed. + process.exit(1); +} diff --git a/src/server/ranked-service.ts b/src/server/ranked-service.ts index f738433..9111ac8 100644 --- a/src/server/ranked-service.ts +++ b/src/server/ranked-service.ts @@ -4,15 +4,18 @@ import { COSMETIC_COLORS, EQUIPMENT_IDS, EYEWEAR_IDS, HAIR_STYLES, SKIN_TONES } 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, + RANKED_STATS_CACHE_LIMIT, RANKED_STATS_CACHE_TTL_MS, RANKED_LEADERBOARD_CACHE_LIMIT, RANKED_LEADERBOARD_CACHE_TTL_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 type { Leaderboard, LeaderboardEntry, 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'; +import { createGlobalSettingsStore } from './global-settings.js'; +import { matchIntroductionDuration } from '../shared/match-introduction.js'; interface ProfileDocument { _id: string; displayName: string; nameVersion?: number; appearance: FighterAppearance; environment: string; createdAt: Date; updatedAt: Date; @@ -33,7 +36,7 @@ function generatedName(): string { } // 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') { +export async function createRankedService(db: Db, clock: Clock = systemClock, environment = 'production', onError?: (code: string) => void) { const profiles = db.collection('player_profiles'); const results = db.collection('ranked_matches'); // Finish migrating every legacy name before any profile or leaderboard can be served. @@ -48,6 +51,11 @@ export async function createRankedService(db: Db, clock: Clock = systemClock, en const queue = new Map(); const byPlayer = new Map(); const matches = new Set(); + const statsCache = new Map }>(); + let resultRevision = 0; + let leaderboardCache: { expiresAt: number; entries: LeaderboardEntry[]; + selves: Map; complete: boolean } | undefined; + let leaderboardRead: Promise | undefined; let tail: Promise = Promise.resolve(), closed = false; function serial(work: () => Promise): Promise { const next = tail.then(() => { @@ -92,6 +100,43 @@ export async function createRankedService(db: Db, clock: Clock = systemClock, en stats.wins++; stats.current++; stats.best = Math.max(stats.best, stats.current); } else { stats.losses++; stats.current = 0; } } + async function readStats(playerId: string): Promise { + let cached = statsCache.get(playerId); + if (!cached || cached.expiresAt <= clock.now()) { + const value = (async () => { + // A concurrent save removes this promise from the cache. Its existing callers + // may receive the earlier snapshot, but the next read rebuilds only this player. + const stats = emptyStats(); + for await (const result of results.find({ playerIds: playerId }).sort({ order: 1, _id: 1 })) addResult(stats, result, playerId); + return stats; + })(); + cached = { expiresAt: clock.now() + RANKED_STATS_CACHE_TTL_MS, value }; + } + statsCache.delete(playerId); statsCache.set(playerId, cached); + if (statsCache.size > RANKED_STATS_CACHE_LIMIT) statsCache.delete(statsCache.keys().next().value!); + try { return { ...await cached.value }; } + catch (error) { + if (statsCache.get(playerId) === cached) statsCache.delete(playerId); + throw error; + } + } + async function scanLeaderboard(): Promise { + const revision = resultRevision, totals = new Map(); + for await (const result of results.find().sort({ order: 1, _id: 1 })) for (const id of result.playerIds) { + const stats = totals.get(id) ?? emptyStats(); addResult(stats, result, id); totals.set(id, stats); + } + const entries = (await profiles.find({ _id: { $in: [...totals.keys()] } }).toArray()).map(profile => ({ playerId: profile._id, + displayName: profile.displayName, wins: totals.get(profile._id)!.wins, best: totals.get(profile._id)!.best, + })).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 an overlapping snapshot without caching it; retrying here can starve + // under steady saves. The next request refreshes, independent of gameplay. + if (!closed && revision === resultRevision) leaderboardCache = { expiresAt: clock.now() + RANKED_LEADERBOARD_CACHE_TTL_MS, + entries: entries.slice(0, RANKED_LEADERBOARD_LIMIT), + selves: new Map(entries.slice(0, RANKED_LEADERBOARD_CACHE_LIMIT).map(entry => [entry.playerId, entry])), + complete: entries.length <= RANKED_LEADERBOARD_CACHE_LIMIT }; + return entries; + } async function readProfile(playerId: string): Promise { if (!validId(playerId)) throw new ApiError(400, 'INVALID_PROFILE'); const now = new Date(clock.now()); @@ -100,8 +145,7 @@ export async function createRankedService(db: Db, clock: Clock = systemClock, en 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); + const stats = await readStats(playerId); return { playerId, displayName: doc.displayName, appearance: normalizeAppearance(doc.appearance), stats, unlockedColors: availableColorIds(stats), unlockedEquipment: availableEquipmentIds(stats) }; } @@ -119,6 +163,13 @@ export async function createRankedService(db: Db, clock: Clock = systemClock, en match.saved = true; match.engine.saved(emptyPlayerStats()); } catch (error) { match.engine.saveFailed(); throw error; } + finally { + // Invalidate even after an error: Mongo may have committed before losing its response. + // Replaying immutable records avoids double awards on retries and service restarts. + resultRevision++; + for (const playerId of match.playerIds) statsCache.delete(playerId); + leaderboardCache = undefined; + } } function schedule(match: RankedMatch) { if (match.timer !== undefined) clock.clearTimeout(match.timer); @@ -133,8 +184,10 @@ export async function createRankedService(db: Db, clock: Clock = systemClock, en : 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(() => {}); + // advance schedules persistence retries itself; never send raw DB errors to the logger. + void serial(() => advance(match)).catch(() => { + try { onError?.('RANKED_BACKGROUND_FAILED'); } catch { /* Logging must not break retries. */ } + }); }, Math.max(0, at - clock.now())); } async function advance(match: RankedMatch) { @@ -145,7 +198,7 @@ export async function createRankedService(db: Db, clock: Clock = systemClock, en 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 (state.phase === 'selecting' && (match.actions.every(action => action !== null) || 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); @@ -215,16 +268,27 @@ export async function createRankedService(db: Db, clock: Clock = systemClock, en }, async leaderboard(playerId: string): Promise { if (closed) throw new ApiError(503, 'RANKED_CLOSED'); - await readProfile(playerId); - const totals = new Map(); - for await (const result of results.find().sort({ order: 1, _id: 1 })) for (const id of result.playerIds) { - const stats = totals.get(id) ?? emptyStats(); addResult(stats, result, id); totals.set(id, stats); + const profile = await readProfile(playerId); + const cached = leaderboardCache; + if (cached && cached.expiresAt > clock.now() && (cached.complete || cached.selves.has(playerId) + || profile.stats.wins + profile.stats.losses + profile.stats.draws === 0)) { + const self = cached.selves.get(playerId); + if (cached.selves.has(playerId)) { cached.selves.delete(playerId); cached.selves.set(playerId, self); } + return structuredClone({ entries: cached.entries, self }); } - 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) }; + // One scan serves concurrent viewers without taking the gameplay operation lock. + const pending = leaderboardRead ??= scanLeaderboard(); + try { + const entries = await pending, self = entries.find(entry => entry.playerId === playerId); + if (leaderboardCache) { + leaderboardCache.selves.delete(playerId); leaderboardCache.selves.set(playerId, self); + if (leaderboardCache.selves.size > RANKED_LEADERBOARD_CACHE_LIMIT) { + leaderboardCache.selves.delete(leaderboardCache.selves.keys().next().value!); + leaderboardCache.complete = false; + } + } + return structuredClone({ entries: entries.slice(0, RANKED_LEADERBOARD_LIMIT), self }); + } finally { if (leaderboardRead === pending) leaderboardRead = undefined; } }, assertAvailable(playerId: string): Promise { return serial(async () => { expireQueue(); if (queue.has(playerId) || byPlayer.has(playerId)) throw new ApiError(409, 'MATCH_PENDING'); }); @@ -257,10 +321,10 @@ export async function createRankedService(db: Db, clock: Clock = systemClock, en 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 id = randomUUID(), engine = createMatchEngine({ matchId: id, mode: 'friend', selfIndex: 0, clock, settings: await createGlobalSettingsStore(db).get() }); 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); + engine.start(clock.now() + matchIntroductionDuration(engine.view().settings, true)); matches.add(match); for (const id of match.playerIds) { queue.delete(id); byPlayer.set(id, match); } schedule(match); break; } @@ -311,7 +375,7 @@ export async function createRankedService(db: Db, clock: Clock = systemClock, en // 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(); + byPlayer.clear(); matches.clear(); statsCache.clear(); leaderboardCache = undefined; }); }, }; diff --git a/src/server/rooms.ts b/src/server/rooms.ts index cfa7ab7..a728ff1 100644 --- a/src/server/rooms.ts +++ b/src/server/rooms.ts @@ -8,13 +8,17 @@ 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'; +import { createGlobalSettingsStore } from './global-settings.js'; +import type { GlobalSettings } from '../shared/global-settings.js'; interface RoomDocument { + settings?: GlobalSettings; _id: string; code: string; hostGameSessionId: string; guestGameSessionId?: string; members: string[]; open: boolean; status: 'waiting' | 'paired' | 'closed'; currentMatchId: string; round: number; rematchReady: string[]; createdAt: Date; expiresAt: Date; appearances?: Pair; } export async function createRoomStore(db: Db, now: () => number = Date.now) { + const settings = createGlobalSettingsStore(db); const rooms = db.collection('rooms'); await rooms.createIndex({ code: 1 }, { unique: true }); await rooms.createIndex({ expiresAt: 1 }, { expireAfterSeconds: 0 }); @@ -70,7 +74,7 @@ export async function createRoomStore(db: Db, now: () => number = Date.now) { 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 }, + $set: { guestGameSessionId: sessionId, status: 'paired', settings: await settings.get(), 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) { @@ -91,7 +95,7 @@ export async function createRoomStore(db: Db, now: () => number = Date.now) { if (await db.collection('game_sessions').findOne({ _id: { $in: before.members }, activeMatch: { $exists: true } })) throw new ApiError(409, 'MATCH_PENDING'); await rooms.updateOne({ _id: id, round, status: 'paired' }, { $addToSet: { rematchReady: sessionId } }); await rooms.updateOne({ _id: id, round, status: 'paired', rematchReady: { $all: before.members }, expiresAt: { $gt: new Date(now()) } }, { - $set: { currentMatchId: randomUUID(), rematchReady: [], expiresAt: new Date(now() + ACTIVE_ROOM_TTL_MS) }, $inc: { round: 1 }, + $set: { currentMatchId: randomUUID(), rematchReady: [], settings: await settings.get(), expiresAt: new Date(now() + ACTIVE_ROOM_TTL_MS) }, $inc: { round: 1 }, }); return view(await read(id, sessionId), sessionId); }, diff --git a/src/shared/global-settings.ts b/src/shared/global-settings.ts new file mode 100644 index 0000000..6fb1231 --- /dev/null +++ b/src/shared/global-settings.ts @@ -0,0 +1,11 @@ +import { GLOBAL_SETTINGS_LIMITS } from '../../constants/global-settings.js'; + +export interface GlobalSettings { revision: number; turnDurationMs: number; finishCutsceneSpeed: number; matchingDelayMs: number; matchSuccessDelayMs: number; matchPreparationDelayMs: number } +export function isGlobalSettings(value: unknown): value is GlobalSettings { + if (!value || typeof value !== 'object') return false; + const s = value as GlobalSettings, limits = GLOBAL_SETTINGS_LIMITS; + return Number.isSafeInteger(s.revision) && s.revision >= 1 + && Number.isSafeInteger(s.turnDurationMs) && s.turnDurationMs >= limits.turnDurationMs.min && s.turnDurationMs <= limits.turnDurationMs.max + && Number.isFinite(s.finishCutsceneSpeed) && s.finishCutsceneSpeed >= limits.finishCutsceneSpeed.min && s.finishCutsceneSpeed <= limits.finishCutsceneSpeed.max + && (['matchingDelayMs', 'matchSuccessDelayMs', 'matchPreparationDelayMs'] as const).every(key => Number.isSafeInteger(s[key]) && s[key] >= limits[key].min && s[key] <= limits[key].max); +} diff --git a/src/shared/match-engine.ts b/src/shared/match-engine.ts index ff840b6..84d1d52 100644 --- a/src/shared/match-engine.ts +++ b/src/shared/match-engine.ts @@ -1,4 +1,6 @@ -import { MATCH_COUNTDOWN_MS, REVEAL_DURATION_MS, TURN_DURATION_MS } from '../../constants/gameplay.js'; +import { MATCH_COUNTDOWN_MS, REVEAL_DURATION_MS } from '../../constants/gameplay.js'; +import { DEFAULT_GLOBAL_SETTINGS } from '../../constants/global-settings.js'; +import { matchPreparationDuration } from './match-introduction.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'; @@ -10,7 +12,7 @@ export function createMatchEngine(config: MatchConfig) { 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, + saveStatus: 'pending', aborted: false, settings: { ...(config.settings ?? DEFAULT_GLOBAL_SETTINGS) }, }; function finish(winnerIndex: 0 | 1 | null, reason: LocalEndReason, aborted = false) { if (state.phase === 'finished') return; @@ -21,14 +23,14 @@ export function createMatchEngine(config: MatchConfig) { return { start(startsAt = config.clock.now()) { if (state.version !== 0) throw new Error('ALREADY_STARTED'); - state.startsAt = startsAt + MATCH_COUNTDOWN_MS; state.version++; + state.startsAt = startsAt + (config.mode === 'friend' ? matchPreparationDuration(state.settings) : 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.startsAt = startsAt; state.deadline = startsAt + (state.settings ?? DEFAULT_GLOBAL_SETTINGS).turnDurationMs; state.ownAction = null; state.reveal = undefined; bot = config.mode === 'bot' ? chooseBotAction(state.energy[1], state.energy[0], config.random) : null; return true; @@ -40,7 +42,7 @@ export function createMatchEngine(config: MatchConfig) { botAction: () => bot, resolve(actions: Pair): TurnResolution { if (state.phase !== 'selecting') throw new Error('NOT_SELECTING'); - if (config.clock.now() < state.deadline) throw new Error('BEFORE_DEADLINE'); + if (config.clock.now() < state.startsAt || (actions.includes(null) && 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; @@ -70,7 +72,7 @@ export function createMatchEngine(config: MatchConfig) { 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++; + state.startsAt = startsAt; state.deadline = startsAt + (state.settings ?? DEFAULT_GLOBAL_SETTINGS).turnDurationMs; state.opponentPresent = true; state.version++; }, abort(reason: 'connection_failed' | 'protocol_error') { finish(null, reason, true); }, saved(stats: PlayerStats) { diff --git a/src/shared/match-introduction.ts b/src/shared/match-introduction.ts new file mode 100644 index 0000000..176d4af --- /dev/null +++ b/src/shared/match-introduction.ts @@ -0,0 +1,20 @@ +import { MATCH_COUNTDOWN_MS } from '../../constants/gameplay.js'; +import type { GlobalSettings } from './global-settings.js'; +import type { LocalMatchState } from './p2p-types.js'; + +export function matchPreparationDuration(settings: GlobalSettings | undefined) { + return settings?.matchPreparationDelayMs ?? MATCH_COUNTDOWN_MS; +} + +export function matchIntroductionDuration(settings: GlobalSettings | undefined, ranked: boolean) { + return (settings?.matchSuccessDelayMs ?? 0) + (ranked ? settings?.matchingDelayMs ?? 0 : 0); +} + +export function matchIntroductionMessage(state: LocalMatchState, now: number): string | undefined { + if (state.mode !== 'friend' || state.phase !== 'countdown') return; + const mode = state.ranked ? '랭킹대전' : '친구 대전'; + if (!Number.isFinite(state.startsAt)) return `${mode} 매칭 성공!`; + const remaining = state.startsAt - now - matchPreparationDuration(state.settings); + if (state.ranked && remaining > (state.settings?.matchSuccessDelayMs ?? 0)) return `${mode} 매칭 중…`; + return remaining > 0 ? `${mode} 매칭 성공!` : `${mode} 준비 중…`; +} diff --git a/src/shared/p2p-types.ts b/src/shared/p2p-types.ts index eda3398..0da55e7 100644 --- a/src/shared/p2p-types.ts +++ b/src/shared/p2p-types.ts @@ -1,9 +1,10 @@ import type { ActionId, EndReason, MatchFinished, MatchState, Mode, Pair, PlayerStats } from './types.js'; import type { FighterAppearance } from './cosmetics.js'; +import type { GlobalSettings } from './global-settings.js'; export type LocalOutcome = 'win' | 'loss' | 'draw' | 'aborted'; export type LocalEndReason = EndReason | 'connection_failed' | 'protocol_error'; -export interface ActiveMatch { matchId: string; seq: number; mode: Mode; roomId?: string; appearances?: Pair } +export interface ActiveMatch { matchId: string; seq: number; mode: Mode; roomId?: string; appearances?: Pair; settings?: GlobalSettings } 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 } @@ -22,6 +23,7 @@ export interface SignalingPacket { } export interface IceConfiguration { iceServers: RTCIceServer[]; expiresAt: number } export interface LocalMatchState extends Omit { + settings?: GlobalSettings; clockNow: number; aborted: boolean; appearances?: Pair; ranked?: boolean; @@ -29,6 +31,7 @@ export interface LocalMatchState extends Omit number; } export interface PeerHeader { diff --git a/tests/cosmetics.test.ts b/tests/cosmetics.test.ts index c6ff620..069606a 100644 --- a/tests/cosmetics.test.ts +++ b/tests/cosmetics.test.ts @@ -216,5 +216,5 @@ test('ranked results allow requeue after saving even when the opponent has left' 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, '상대가 나갔습니다.'); + assert.equal(container.querySelector('.rematch-status').textContent, '상대방이 나갔습니다'); }); diff --git a/tests/equipment-art.test.ts b/tests/equipment-art.test.ts index ec21e8b..65e9d3c 100644 --- a/tests/equipment-art.test.ts +++ b/tests/equipment-art.test.ts @@ -1,3 +1,4 @@ +import { DEFAULT_GLOBAL_SETTINGS } from '../constants/global-settings.js'; import assert from 'node:assert/strict'; import test from 'node:test'; import { createHash } from 'node:crypto'; @@ -231,13 +232,15 @@ test('the archived Yamcha function remains byte-for-byte unchanged', () => { assert.equal(createHash('sha256').update(archived).digest('hex'), 'b540712bc652b3380812299c7889c64ad4b0ae18a070d5dfa9f42e157ea5be2c'); }); -function domHarness(t: import('node:test').TestContext) { +function domHarness(t: import('node:test').TestContext, reducedMotion = true) { class Element { dataset: Record = {}; textContent = ''; innerHTML = ''; outerHTML = ''; hidden = false; children = new Map(); - style = { setProperty() {}, removeProperty() {} }; - classList = { toggle() {} }; + properties = new Map(); + style = { setProperty: (key: string, value: string) => { this.properties.set(key, value); }, removeProperty: (key: string) => { this.properties.delete(key); } }; + classList = { toggle() {}, contains() { return false; } }; + getBoundingClientRect() { return { left: 0, width: 100 }; } setAttribute() {} addEventListener() {} showModal() {} close() {} remove() {} focus() { doc.activeElement = this; } append(node: Element) { appended.push(node); } @@ -251,7 +254,7 @@ function domHarness(t: import('node:test').TestContext) { 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() {} }), + matchMedia: () => ({ matches: reducedMotion, addEventListener() {}, removeEventListener() {} }), } }; for (const [key, value] of Object.entries(globals)) { const previous = Object.getOwnPropertyDescriptor(globalThis, key); @@ -289,26 +292,115 @@ test('match updates react to hair and equipment changes without rebuilding uncha } finally { view.destroy(); } }); -test('an open cutscene refreshes changed appearance art without restarting its stage or result control', t => { - const { appended, state } = domHarness(t); +test('an open cutscene refreshes changed appearance art without restarting its stage', t => { + const { appended, state } = domHarness(t, false); 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.reveal = { matchId: state.matchId, turnId: 1, actions: ['blast', 'charge'], energyBefore: [3, 3], energy: [2, 3], hitIndex: 1, winnerIndex: 0, revealedAt: state.clockNow, revealEndsAt: state.clockNow + 1200 }; 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'); + const dialog = appended[0]; + assert.equal(dialog.dataset.stage, 'attack'); 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); + assert.equal(dialog.dataset.stage, 'attack'); page.outerHTML += ''; cutscene.update({ ...state, version: 3 }); assert.ok(page.outerHTML.endsWith('')); assert.equal(appended.length, 1); } finally { cutscene.destroy(); } }); + +test('global cutscene speed scales stages and CSS timing while respecting result availability', t => { + const { appended, state } = domHarness(t, false); + const cases = [ + [1, 600, 'attack'], [2, 600, 'impact'], [2, 1250, 'reaction'], + [2, 1550, 'fallen'], [2, 1850, 'complete'], [0.5, 1850, 'attack'], + [4, 950, 'fallen'], [4, 1250, 'complete'], + ] as const; + for (const [speed, elapsed, expected] of cases) { + state.matchId = `speed-${speed}-${elapsed}`; state.phase = 'finished'; + state.settings = { ...DEFAULT_GLOBAL_SETTINGS, revision: 2, turnDurationMs: 3000, finishCutsceneSpeed: speed }; + const revealedAt = state.clockNow - elapsed; + state.reveal = { matchId: state.matchId, turnId: 1, actions: ['beam', 'guard'], energyBefore: [3, 3], energy: [0, 3], hitIndex: 1, winnerIndex: 0, revealedAt, revealEndsAt: revealedAt + 1200 }; + state.finished = { matchId: state.matchId, winnerIndex: 0, reason: 'hit', stats: emptyPlayerStats(), revealEndsAt: state.reveal.revealEndsAt, saved: true }; + let transitions = 0; + const cutscene = createFinishCutscene(() => { transitions++; }); + try { + cutscene.update(state); + const dialog = appended.at(-1)!; + assert.equal(dialog.dataset.stage, expected, `${speed}x at ${elapsed}ms`); + assert.equal(dialog.properties.get('--cutscene-time-scale'), String(1 / speed)); + assert.equal(transitions, expected === 'complete' ? 1 : 0); + assert.equal(dialog.innerHTML.includes('cutscene-result'), false); + } finally { cutscene.destroy(); } + } +}); + +for (const reducedMotion of [false, true]) test(`cutscene timer automatically opens results once (reduced motion: ${reducedMotion})`, t => { + const { state } = domHarness(t, reducedMotion); + t.mock.timers.enable({ apis: ['setTimeout'] }); + let now = 0, transitions = 0; + t.mock.method(performance, 'now', () => now); + state.matchId = `automatic-result-${reducedMotion}`; state.phase = 'finished'; + state.reveal = { matchId: state.matchId, turnId: 1, actions: ['blast', 'charge'], energyBefore: [1, 1], energy: [0, 1], hitIndex: 1, winnerIndex: 0, + revealedAt: state.clockNow, revealEndsAt: state.clockNow + 1200 }; + state.finished = { matchId: state.matchId, winnerIndex: 0, reason: 'hit', stats: emptyPlayerStats(), revealEndsAt: state.reveal.revealEndsAt, saved: true }; + const cutscene = createFinishCutscene(() => { transitions++; }); + try { + cutscene.update(state); + const end = reducedMotion ? 1200 : 3600; + for (now = 1; now < end; now++) t.mock.timers.tick(1); + assert.equal(transitions, 0); + t.mock.timers.tick(1); + assert.equal(transitions, 1); + cutscene.update(state); + now += 5000; t.mock.timers.tick(5000); + assert.equal(transitions, 1); + } finally { cutscene.destroy(); } +}); + +test('rematch updates cannot replay the final attack or hide an already displayed result', t => { + const { container, actors, state } = domHarness(t, false); + state.matchId = 'rematch-final-reveal'; state.phase = 'finished'; + state.reveal = { matchId: state.matchId, turnId: 2, actions: ['blast', 'charge'], energyBefore: [1, 1], energy: [0, 1], hitIndex: 1, winnerIndex: 0, + revealedAt: state.clockNow - 5000, revealEndsAt: state.clockNow - 3800 }; + state.finished = { matchId: state.matchId, winnerIndex: 0, reason: 'hit', stats: emptyPlayerStats(), revealEndsAt: state.reveal.revealEndsAt, saved: true }; + const view = createMatchView(container as unknown as HTMLElement, async () => true, () => {}, () => {}); + try { + view.update(state); + assert.equal(container.querySelector('.match-result').hidden, false); + for (const clockNow of [state.clockNow + 1000, state.reveal.revealedAt + 100]) { + view.update({ ...state, version: 2, clockNow, rematch: [false, true] }); + assert.equal(actors[0].dataset.action, undefined); + assert.equal(actors[1].dataset.hit, undefined); + assert.equal(container.querySelector('.match-result').hidden, false); + assert.equal(container.querySelector('.rematch-status').textContent, '상대가 재대전을 요청했습니다.'); + } + const nextId = 'rematch-next-round'; + view.update({ ...state, matchId: nextId, version: 1, phase: 'revealing', finished: undefined, + reveal: { ...state.reveal, matchId: nextId, revealedAt: state.clockNow, revealEndsAt: state.clockNow + 1200 } }); + assert.equal(actors[0].dataset.action, 'blast', 'the next match still plays its own actions'); + } finally { view.destroy(); } +}); + +test('a dismissed finisher stays closed when the opponent disconnects after the result', t => { + const { appended, state } = domHarness(t, false); + state.matchId = 'dismissed-peer-result'; state.phase = 'finished'; + state.reveal = { matchId: state.matchId, turnId: 2, actions: ['blast', 'charge'], energyBefore: [1, 1], energy: [0, 1], hitIndex: 1, winnerIndex: 0, + revealedAt: state.clockNow - 5000, revealEndsAt: state.clockNow - 3800 }; + state.finished = { matchId: state.matchId, winnerIndex: 0, reason: 'hit', stats: emptyPlayerStats(), revealEndsAt: state.reveal.revealEndsAt, saved: true }; + let dismissals = 0; + const cutscene = createFinishCutscene(() => { dismissals++; }); + try { + cutscene.update(state); + assert.equal(appended[0].dataset.stage, 'complete'); + assert.equal(dismissals, 1, 'completion automatically opens the result'); + cutscene.update({ ...state, version: 2, opponentPresent: false, clockNow: state.clockNow + 1000 }); + assert.equal(dismissals, 1); assert.equal(appended.length, 1); + } finally { cutscene.destroy(); } +}); diff --git a/tests/global-settings.test.ts b/tests/global-settings.test.ts new file mode 100644 index 0000000..b84323c --- /dev/null +++ b/tests/global-settings.test.ts @@ -0,0 +1,88 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { randomBytes, randomUUID } from 'node:crypto'; +import { MongoClient } from 'mongodb'; +import { DEFAULT_GLOBAL_SETTINGS, GLOBAL_SETTINGS_CACHE_KEY } from '../constants/global-settings.js'; +import { createGlobalSettingsCache } from '../src/client/global-settings.js'; +import { createGameServer } from '../src/server/create-server.js'; +import { createGlobalSettingsStore } from '../src/server/global-settings.js'; +import { createGameSessionStore } from '../src/server/game-session-store.js'; +import { createRoomStore } from '../src/server/rooms.js'; +import { createMatchEngine } from '../src/shared/match-engine.js'; + +test('browser caches settings, checks revision and retains valid settings through failures', async () => { + const memory = new Map(), urls: string[] = []; + let settings = { ...DEFAULT_GLOBAL_SETTINGS }, offline = false, writes = 0; + const storage = { getItem: (key: string) => memory.get(key) ?? null, setItem: (key: string, value: string) => { writes++; memory.set(key, value); } }; + const send = async (input: any) => { + urls.push(String(input)); if (offline) throw new Error('offline'); + const revision = new URL(String(input), 'http://test').searchParams.get('revision'); + return Response.json({ ok: true, data: { revision: settings.revision, ...(Number(revision) === settings.revision ? {} : { settings }) } }); + }; + const cache = createGlobalSettingsCache(storage, send); + assert.deepEqual(await cache.refresh(), settings); assert.equal(writes, 1); + const reloaded = createGlobalSettingsCache(storage, send); + assert.deepEqual(await reloaded.refresh(), settings); assert.equal(urls.at(-1), '/api/global-settings?revision=1'); assert.equal(writes, 1); + settings = { ...DEFAULT_GLOBAL_SETTINGS, revision: 2, turnDurationMs: 2000, finishCutsceneSpeed: 2 }; + assert.deepEqual(await reloaded.refresh(), settings); assert.equal(writes, 2); + offline = true; assert.deepEqual(await reloaded.refresh(), settings); + memory.set(GLOBAL_SETTINGS_CACHE_KEY, '{bad json'); + assert.deepEqual(await createGlobalSettingsCache(storage, send).refresh(), DEFAULT_GLOBAL_SETTINGS); +}); + +test('match settings survive reload and early resolution still rejects missing choices', () => { + let now = 0; + const settings = { ...DEFAULT_GLOBAL_SETTINGS, revision: 2, turnDurationMs: 5000, finishCutsceneSpeed: 2 }; + const config = { matchId: 'settings', mode: 'friend' as const, selfIndex: 0 as const, clock: { now: () => now } }; + const engine = createMatchEngine({ ...config, settings }); + engine.start(); now = 3000; engine.beginTurn(); + assert.equal(engine.view().deadline, 8000); + assert.throws(() => engine.resolve(['charge', null]), /BEFORE_DEADLINE/); + engine.resolve(['charge', 'charge']); assert.equal(engine.view().phase, 'revealing'); + const restored = createMatchEngine(config); restored.restore(engine.checkpoint()); + now = 4200; restored.beginTurn(); + assert.equal(restored.view().deadline, 9200); assert.deepEqual(restored.view().settings, settings); +}); + +test('DB settings update without restart and friend matches share an immutable snapshot', { skip: !process.env.TEST_MONGODB_URI }, async () => { + const databaseName = 'dragon_ball_test_settings_' + randomUUID().replaceAll('-', ''); + const client = await new MongoClient(process.env.TEST_MONGODB_URI!).connect(), db = client.db(databaseName); + const app = await createGameServer({ mongoUri: process.env.TEST_MONGODB_URI!, databaseName, clientDirectory: 'dist/client', adminUser: 'test', adminPassword: 'test-password', environment: 'test' }); + const headers = { authorization: 'Basic ' + Buffer.from('test:test-password').toString('base64') }; + try { + const store = createGlobalSettingsStore(db); + assert.deepEqual(await store.get(), DEFAULT_GLOBAL_SETTINGS); + assert.equal((await app.inject({ method: 'POST', url: '/api/admin/global-settings', payload: DEFAULT_GLOBAL_SETTINGS })).statusCode, 401); + const updated = { ...DEFAULT_GLOBAL_SETTINGS, revision: 1, turnDurationMs: 5000, finishCutsceneSpeed: 2 }; + const save = await app.inject({ method: 'POST', url: '/api/admin/global-settings', headers, payload: updated }); + assert.equal(save.statusCode, 200); assert.equal(save.json().data.revision, 2); + assert.equal((await app.inject({ method: 'POST', url: '/api/admin/global-settings', headers, payload: updated })).statusCode, 409); + assert.equal((await app.inject({ method: 'POST', url: '/api/admin/global-settings', headers, payload: { ...updated, revision: 2, finishCutsceneSpeed: 0 } })).statusCode, 400); + assert.equal((await app.inject('/api/global-settings?revision=1')).json().data.settings.turnDurationMs, 5000); + assert.deepEqual((await app.inject('/api/global-settings?revision=2')).json().data, { revision: 2 }); + 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')); + const room = await rooms.create(a.gameSessionId, 'create'); await rooms.join(b.gameSessionId, room.code); + const input = { requestId: 'register', matchId: room.currentMatchId, mode: 'friend' as const, roomId: room.id, expectedPreviousSeq: 0 }; + const first = await sessions.register(a, input); + await store.update({ ...DEFAULT_GLOBAL_SETTINGS, revision: 2, turnDurationMs: 2000, finishCutsceneSpeed: 3 }); + const second = await sessions.register(b, input); + assert.deepEqual(first.settings, second.settings); assert.equal(second.settings?.revision, 2); + const c = await sessions.create(randomBytes(32).toString('base64url')); + const bot = await sessions.register(c, { requestId: 'bot', matchId: 'bot', mode: 'bot', expectedPreviousSeq: 0 }); + assert.equal(bot.settings?.revision, 3); assert.equal(bot.settings?.finishCutsceneSpeed, 3); + await db.collection('global_settings').updateOne({ _id: 'global' as any }, { $unset: { matchingDelayMs: '', matchSuccessDelayMs: '', matchPreparationDelayMs: '' } }); + await store.initialize(); + const migrated = await store.get(); + assert.equal(migrated.matchingDelayMs, 1000); assert.equal(migrated.matchSuccessDelayMs, 1000); + assert.equal(migrated.matchPreparationDelayMs, 3000); + assert.equal(migrated.finishCutsceneSpeed, 3); assert.equal(migrated.turnDurationMs, 2000); + assert.ok(migrated.revision > 3); + await store.initialize(); assert.deepEqual(await store.get(), migrated); + const changed = await store.update({ ...migrated, matchingDelayMs: 4000, matchSuccessDelayMs: 2000, matchPreparationDelayMs: 5000 }); + assert.equal(changed.matchingDelayMs, 4000); assert.equal(changed.matchSuccessDelayMs, 2000); + assert.equal((await store.get()).matchPreparationDelayMs, 5000); + await assert.rejects(store.update({ ...changed, matchPreparationDelayMs: -1 }), /INVALID_SETTINGS/); + await assert.rejects(store.update({ ...changed, matchingDelayMs: -1 }), /INVALID_SETTINGS/); + } finally { await app.close(); await db.dropDatabase(); await client.close(); } +}); diff --git a/tests/http-operations.test.ts b/tests/http-operations.test.ts new file mode 100644 index 0000000..567dd16 --- /dev/null +++ b/tests/http-operations.test.ts @@ -0,0 +1,87 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import Fastify from 'fastify'; +import { createHttpOptions, registerHttpOperations, parseTrustProxy, logSafeError } from '../src/server/http-operations.js'; +import { gameRoute } from '../src/server/game-api.js'; +import { registerAdmin } from '../src/server/admin.js'; + +test('session limits precede handlers, ignore spoofed forwarding and expire', async t => { + let now = 0, calls = 0; + const app = Fastify(createHttpOptions({ environment: 'test' })); + t.after(() => app.close()); + registerHttpOperations(app, { now: () => now }); + gameRoute(app, 'POST', '/api/game-sessions', () => { calls++; return {}; }); + for (let i = 0; i < 30; i++) assert.equal((await app.inject({ method: 'POST', url: '/api/game-sessions', headers: { 'x-forwarded-for': `192.0.2.${i}` } })).statusCode, 200); + now = 1001; + const limited = await app.inject({ method: 'POST', url: '/api/game-sessions?secret=x' }); + assert.equal(limited.statusCode, 429); assert.equal(limited.headers['retry-after'], '59'); + assert.match(limited.json().message, /다시/); assert.equal(calls, 30); + now = 60000; + assert.equal((await app.inject({ method: 'POST', url: '/api/game-sessions' })).statusCode, 200); +}); + +test('global budget allows shared IP polling and limits all API routes', async t => { + const app = Fastify(createHttpOptions({ environment: 'test' })); t.after(() => app.close()); + registerHttpOperations(app, { now: () => 0 }); + app.get('/api/poll', () => ({})); app.get('/health', () => ({ status: 'ok' })); + for (let i = 0; i < 1200; i++) assert.equal((await app.inject('/api/poll')).statusCode, 200); + assert.equal((await app.inject('/api/unknown')).statusCode, 429); + assert.equal((await app.inject('/health')).statusCode, 200); +}); + +test('admin HTML and API share a budget before authentication', async t => { + const app = Fastify(createHttpOptions({ environment: 'test' })); t.after(() => app.close()); + registerHttpOperations(app, { now: () => 0 }); + registerAdmin(app, {} as any, {} as any, {} as any, { user: 'test', password: 'password' }, '.'); + for (let i = 0; i < 60; i++) assert.equal((await app.inject(i % 2 ? '/admin/analytics' : '/api/admin/analytics/live')).statusCode, 401); + assert.equal((await app.inject('/api/admin/analytics/report')).statusCode, 429); +}); + +test('explicit loopback proxy trusts only the nearest untrusted hop', async t => { + const app = Fastify(createHttpOptions({ environment: 'test', trustProxy: parseTrustProxy('loopback') })); t.after(() => app.close()); + registerHttpOperations(app, { now: () => 0 }); + app.post('/api/game-sessions', request => ({ ip: request.ip })); + for (let i = 0; i < 30; i++) assert.equal((await app.inject({ method: 'POST', url: '/api/game-sessions', remoteAddress: '127.0.0.1', headers: { 'x-forwarded-for': `198.51.100.${i}, 192.0.2.1` } })).statusCode, 200); + assert.equal((await app.inject({ method: 'POST', url: '/api/game-sessions', headers: { 'x-forwarded-for': '192.0.2.1' } })).statusCode, 429); + const direct = await app.inject({ method: 'POST', url: '/api/game-sessions', remoteAddress: '192.0.2.2', headers: { 'x-forwarded-for': '192.0.2.1' } }); + assert.equal(direct.json().ip, '192.0.2.2'); + assert.equal(parseTrustProxy(undefined), false); + for (const unsafe of ['true', '*', '0.0.0.0/0', '::/0', 'loopback,']) assert.throws(() => parseTrustProxy(unsafe)); +}); + +test('bounded IP storage fails closed and recovers after expiry', async t => { + let now = 0; + const app = Fastify(createHttpOptions({ environment: 'test' })); t.after(() => app.close()); + registerHttpOperations(app, { now: () => now, maxIps: 2 }); app.get('/api/poll', () => ({})); + for (const ip of ['192.0.2.1', '192.0.2.2']) assert.equal((await app.inject({ url: '/api/poll', remoteAddress: ip })).statusCode, 200); + assert.equal((await app.inject({ url: '/api/poll', remoteAddress: '192.0.2.3' })).statusCode, 429); + now = 60000; + assert.equal((await app.inject({ url: '/api/poll', remoteAddress: '192.0.2.3' })).statusCode, 200); +}); + +test('unexpected HTTP, game, readiness and background errors never expose secrets', async t => { + const lines: string[] = [], secret = 'VERY_SECRET_CREDENTIAL'; + const options = createHttpOptions({ environment: 'production' }); + const app = Fastify({ ...options, logger: { ...options.logger as object, stream: { write: (line: string) => lines.push(line) } } }); + t.after(() => app.close()); + registerHttpOperations(app, { ready: async () => { throw new Error(secret); } }); + const fail = () => { throw Object.assign(new Error(secret), { code: secret, name: secret, cause: { password: secret } }); }; + app.post('/api/plain/:id', fail); gameRoute(app, 'POST', '/api/game/:id', fail); + for (const path of ['plain', 'game']) { + const response = await app.inject({ method: 'POST', url: `/api/${path}/${secret}?token=${secret}`, headers: { authorization: `Bearer ${secret}`, 'x-request-id': secret }, payload: { password: secret } }); + assert.equal(response.statusCode, 500); assert.ok(!response.body.includes(secret)); + } + assert.equal((await app.inject('/ready')).statusCode, 503); + logSafeError(app.log, 'ranked_background', new Error(secret)); + assert.ok(!lines.join('').includes(secret)); + const errors = lines.map(line => JSON.parse(line)).filter(line => line.level === 50); + assert.equal(errors.length, 4); assert.equal(errors[0].route, '/api/plain/:id'); +}); + +test('readiness succeeds and malformed bodies receive sanitized client errors', async t => { + const app = Fastify(createHttpOptions({ environment: 'test' })); t.after(() => app.close()); + registerHttpOperations(app, { ready: async () => {} }); app.post('/api/body', () => ({})); + assert.equal((await app.inject('/ready')).statusCode, 200); + const response = await app.inject({ method: 'POST', url: '/api/body', headers: { 'content-type': 'application/json' }, payload: '{"secret":"UNSAFE' }); + assert.equal(response.statusCode, 400); assert.ok(!response.body.includes('UNSAFE')); +}); diff --git a/tests/lobby-profile.test.ts b/tests/lobby-profile.test.ts index 8bb5f09..b8b11ad 100644 --- a/tests/lobby-profile.test.ts +++ b/tests/lobby-profile.test.ts @@ -1,9 +1,13 @@ +import { DEFAULT_GLOBAL_SETTINGS } from '../constants/global-settings.js'; +import { matchIntroductionDuration, matchIntroductionMessage } from '../src/shared/match-introduction.js'; 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'; +import { createMatchEngine } from '../src/shared/match-engine.js'; +import { RANKED_POLL_MS } from '../constants/ranked.js'; // A selector/event surface, not an HTML parser or rendering implementation. // The test invokes the production selection/equip handlers and observes the @@ -34,6 +38,57 @@ function documentSurface() { return document; } +for (const initiallyQueued of [false, true]) test(`ranked introduction follows server countdown (queued: ${initiallyQueued})`, 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'); + }); + t.mock.timers.enable({ apis: ['setTimeout'] }); + let now = 0; + const engine = createMatchEngine({ matchId: 'ranked-intro', mode: 'friend', selfIndex: 0, clock: { now: () => now } }); + engine.start(matchIntroductionDuration(DEFAULT_GLOBAL_SETTINGS, true)); + const profile: PlayerProfile = { + playerId: 'player-a', displayName: 'Alpha', appearance: normalizeAppearance(undefined), + stats: { wins: 0, losses: 0, draws: 0, current: 0, best: 0 }, unlockedColors: ['white'], unlockedEquipment: [], + }; + const screens: string[] = []; + const api = { async request(path: string) { + if (path === '/api/profile') return profile; + if (path === '/api/ranked/cancel' || path === '/api/ranked/leave') return { status: 'idle', profile }; + if (initiallyQueued && path === '/api/ranked/queue') return { status: 'queued', profile }; + if (now >= engine.view().startsAt) engine.beginTurn(); + return { status: 'matched', profile, match: { ...engine.view(), ranked: true } }; + } } as unknown as GameApi; + const lobby = createLobbyFeatures({ api, onQueue: message => screens.push(message), + onMatch: state => screens.push(matchIntroductionMessage(state, state.clockNow) ?? state.phase), onHome: () => screens.push('home'), + onAppearance() {}, onAvailabilityChange() {}, onError(error) { throw error; }, + }); + const flush = async () => { for (let i = 0; i < 5; i++) await Promise.resolve(); }; + const advance = async (ms: number) => { + for (let elapsed = 0; elapsed < ms; elapsed += RANKED_POLL_MS) { + now += RANKED_POLL_MS; t.mock.timers.tick(RANKED_POLL_MS); await flush(); + } + }; + await lobby.initialize(false); + document.getElementById('ranked-start').onclick!(); + assert.equal(screens.at(-1), '랭킹대전 매칭 중…', 'show search before the queue response'); + await flush(); + assert.equal(screens.includes('countdown'), false); + await advance(DEFAULT_GLOBAL_SETTINGS.matchingDelayMs); + assert.equal(screens.at(-1), '랭킹대전 매칭 성공!'); + await advance(DEFAULT_GLOBAL_SETTINGS.matchSuccessDelayMs); + assert.equal(screens.at(-1), '랭킹대전 준비 중…'); + await advance(3000); + assert.equal(screens.at(-1), 'selecting'); + assert.equal(engine.view().deadline - now, engine.view().settings!.turnDurationMs, 'retain the full first turn'); + await lobby.leave(); + const count = screens.length; + await advance(1000); + assert.equal(screens.length, count, 'leaving stops presentation updates'); +}); + 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 }); diff --git a/tests/main-recovery.test.ts b/tests/main-recovery.test.ts index 66a41e0..37949ea 100644 --- a/tests/main-recovery.test.ts +++ b/tests/main-recovery.test.ts @@ -6,12 +6,13 @@ 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 { createGlobalSettingsCache } from '../src/client/global-settings.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') { +async function setup(kind: 'expired' | 'orphan' | 'rematch-wait' | 'result') { const mem = new Map(), calls: string[] = [], polls = new Map void>(), launched: string[] = []; const active = { matchId: 'm', seq: 1, mode: 'friend', roomId: 'r' }; const session: any = { gameSessionId: 's', token: 't'.repeat(43), stats: emptyPlayerStats(), lastAppliedSeq: kind === 'orphan' ? 0 : 1, ...(kind === 'orphan' ? { activeMatch: active } : {}) }; @@ -36,8 +37,10 @@ async function setup(kind: 'expired' | 'orphan' | 'rematch-wait') { const nodes = new Map(); const el = (id: string) => { if (!nodes.has(id)) nodes.set(id, { classList: { toggle() {}, add() {}, remove() {} }, addEventListener() {}, focus() {} }); return nodes.get(id); }; const ctx: any = { Map, Date, Promise, JSON, URL, URLSearchParams, Number, crypto, ApiClientError, GAME_CHECKPOINT_KEY, GAME_ROOM_KEY, GUEST_TOKEN_KEY: 'guest', ROOM_POLL_MS: 1000, ACTIONS: {}, ACTION_IDS: [], - document: { getElementById: el, body: { dataset: {} }, querySelectorAll: () => [] }, location: { origin: 'http://local', search: '' }, sessionStorage: storage, localStorage: { getItem: () => null, setItem() {} }, navigator: {}, fighterArt: () => '', + window: { addEventListener() {} }, document: { addEventListener() {}, 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), + createGlobalSettingsCache: () => createGlobalSettingsCache(storage, send), + createAppFooter: () => ({ setConnection() {}, openRules() {} }), createClientAnalytics: () => ({ ready() {}, screen() {}, gameFinished() {}, gameStarted() {}, error() {}, click() {} }), createResultReporter: (api: any) => ({ destroy() {}, resumePending: async () => {}, submit: api.record }), createMatchView: () => ({ update() {}, destroy() {} }), createPeer: () => ({}), @@ -45,7 +48,7 @@ async function setup(kind: 'expired' | 'orphan' | 'rematch-wait') { 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(); + vm.createContext(ctx); vm.runInContext(compiled + '\nglobalThis.review={leave,boot,inspect:()=>({ready,currentScreen,currentRoom,currentMatch})};', 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 () => { @@ -61,3 +64,13 @@ test('refresh restores HTTP rematch consent polling and starts an advanced round 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']); }); + +test('a result monitors opponent departure without requesting a rematch', async () => { + const h = await setup('result'); + assert.equal(h.polls.size, 1); + assert.equal(h.ctx.review.inspect().currentMatch.opponentLeft, false); + h.room.status = 'closed'; + [...h.polls.values()][0](); await flush(); + assert.equal(h.ctx.review.inspect().currentMatch.opponentLeft, true); + assert.equal(h.calls.some(path => path.endsWith('/rematch')), false); +}); diff --git a/tests/match-controller.test.ts b/tests/match-controller.test.ts index 235517f..7510c57 100644 --- a/tests/match-controller.test.ts +++ b/tests/match-controller.test.ts @@ -21,9 +21,10 @@ test('local bot runs countdown, turns, result and persistence without Socket.IO' 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]); + assert.equal(h.controller.view().phase, 'revealing'); + 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); + await Promise.resolve(); 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'); diff --git a/tests/match-engine.test.ts b/tests/match-engine.test.ts index 68a51ff..4397d3a 100644 --- a/tests/match-engine.test.ts +++ b/tests/match-engine.test.ts @@ -14,7 +14,7 @@ test('countdown and turn deadlines gate actions without a server', () => { 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.view().deadline, 7000); assert.equal(engine.lockAction('blast'), false); assert.equal(engine.lockAction('charge'), true); assert.equal(engine.lockAction('guard'), false); @@ -71,7 +71,7 @@ test('confirmed reveal timing and recovery keep energy while discarding an uncon 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); + assert.equal(engine.view().ownAction, null); assert.equal(engine.view().deadline, 14000); }); test('rollback and recovery keep published revisions increasing so the view accepts restored states', () => { diff --git a/tests/match-introduction.test.ts b/tests/match-introduction.test.ts new file mode 100644 index 0000000..1e17f35 --- /dev/null +++ b/tests/match-introduction.test.ts @@ -0,0 +1,22 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { DEFAULT_GLOBAL_SETTINGS } from '../constants/global-settings.js'; +import { createMatchEngine } from '../src/shared/match-engine.js'; +import { matchIntroductionDuration, matchIntroductionMessage } from '../src/shared/match-introduction.js'; + +for (const ranked of [false, true]) test(`configurable introduction preserves preparation and first turn (ranked: ${ranked})`, () => { + let now = 0; + const settings = { ...DEFAULT_GLOBAL_SETTINGS, matchingDelayMs: 4000, matchSuccessDelayMs: 2000, matchPreparationDelayMs: 5000 }; + const engine = createMatchEngine({ matchId: 'intro', mode: 'friend', selfIndex: 0, clock: { now: () => now }, settings }); + engine.start(matchIntroductionDuration(settings, ranked)); + const message = () => matchIntroductionMessage({ ...engine.view(), ranked }, now); + assert.equal(message(), ranked ? '랭킹대전 매칭 중…' : '친구 대전 매칭 성공!'); + if (ranked) { now = 4000; assert.equal(message(), '랭킹대전 매칭 성공!'); } + now += 2000; + assert.equal(message(), ranked ? '랭킹대전 준비 중…' : '친구 대전 준비 중…'); + assert.equal(engine.beginTurn(), false); + now += 5000; + assert.equal(engine.beginTurn(), true); + assert.equal(message(), undefined); + assert.equal(engine.view().deadline - now, settings.turnDurationMs); +}); diff --git a/tests/p2p-recovery.test.ts b/tests/p2p-recovery.test.ts index 7f72ebe..31935e8 100644 --- a/tests/p2p-recovery.test.ts +++ b/tests/p2p-recovery.test.ts @@ -32,7 +32,7 @@ class Wire { close() { this.connected = false; } disconnect() { this.connected = false; for (const h of this.states) h('disconnected'); } } -function setup(onRematch?: () => Promise, guestClockOffset = 0) { +function setup(onRematch?: () => Promise, guestClockOffset = 0, guestStartDelay = 0) { const clock = new ManualClock(); clock.time = 1000; const a = new Wire(), b = new Wire(); a.remote = b; b.remote = a; const records: ResultSubmission[][] = [[], []]; @@ -40,7 +40,8 @@ function setup(onRematch?: () => Promise, guestClockOffset = 0) { const controllers = [a, b].map((peer, slot) => { const memory = new Map(); const storage = { getItem: (k: string) => memory.get(k) ?? null, setItem: (k: string, v: string) => { memory.set(k, v); }, removeItem: (k: string) => { memory.delete(k); } }; storages.push(storage); - const localClock = slot === 1 ? { now: () => clock.now() + guestClockOffset, setTimeout: clock.setTimeout, clearTimeout: clock.clearTimeout } : clock; + const localClock = slot === 1 ? { now: () => clock.now() + guestClockOffset, + setTimeout: (fn: () => void, ms: number) => clock.setTimeout(fn, ms + (ms === 4000 || ms === 0 ? guestStartDelay : 0)), 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, @@ -51,9 +52,12 @@ function setup(onRematch?: () => Promise, guestClockOffset = 0) { 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); + await Promise.all(h.controllers.map(c => c.start())); await h.clock.advance(4000); 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); + await a.submitAction('charge'); await h.clock.advance(1); + assert.equal(a.view().phase, 'selecting'); + await b.submitAction('charge'); await h.clock.advance(1); + assert.equal(a.view().phase, 'revealing'); assert.equal(b.view().phase, 'revealing'); 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'); @@ -61,11 +65,28 @@ test('two peers agree turns and record complementary results without server turn } finally { h.close(); } }); +test('an immediate choice survives the other browser starting the announced turn late', async () => { + const h = setup(undefined, 0, 100), [a, b] = h.controllers; + try { + await Promise.all(h.controllers.map(c => c.start())); await h.clock.advance(0); await h.clock.advance(4000); + assert.equal(a.view().phase, 'selecting'); assert.equal(b.view().phase, 'countdown'); + await a.submitAction('charge'); await h.clock.advance(0); await h.clock.advance(100); + await b.submitAction('charge'); await h.clock.advance(1); + assert.equal(a.view().phase, 'revealing'); assert.equal(b.view().phase, 'revealing'); + assert.deepEqual(a.view().energy, [1, 1]); assert.deepEqual(b.view().energy, [1, 1]); + await h.clock.advance(1200); + assert.equal(a.view().phase, 'selecting'); assert.equal(b.view().phase, 'revealing'); + await a.submitAction('blast'); await h.clock.advance(0); await h.clock.advance(100); + await b.submitAction('charge'); await h.clock.advance(1); + assert.equal(a.view().finished?.reason, 'hit'); assert.equal(b.view().finished?.reason, 'hit'); + } 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 Promise.all(h.controllers.map(c => c.start())); await h.clock.advance(4000); + await a.submitAction('charge'); await b.submitAction('charge'); await h.clock.advance(1); await h.clock.advance(1200); 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); @@ -73,6 +94,28 @@ test('loss of final presentation after digest acknowledgements preserves both ag } finally { h.close(); } }); +for (const leavingSlot of [0, 1] as const) test(`leaving a finished match as slot ${leavingSlot} never restarts the other player's final reveal`, async () => { + const h = setup(), [a, b] = h.controllers; + const remainingSlot = leavingSlot === 0 ? 1 : 0; + try { + await Promise.all(h.controllers.map(c => c.start())); await h.clock.advance(4000); + await a.submitAction('charge'); await b.submitAction('charge'); await h.clock.advance(1); await h.clock.advance(1200); + await a.submitAction('blast'); await b.submitAction('charge'); await h.clock.advance(4000); + const remaining = h.controllers[remainingSlot], before = remaining.view(); + assert.equal(before.finished?.reason, 'hit'); assert.equal(before.finished?.saved, true); + assert.ok(before.reveal!.revealEndsAt < h.clock.now()); + h.controllers[leavingSlot].destroy(); + const wire = remainingSlot === 0 ? h.a : h.b; + wire.disconnect(); await h.clock.advance(1); + assert.equal(remaining.view().opponentPresent, false); + assert.deepEqual(remaining.view().reveal, before.reveal); + assert.deepEqual(remaining.view().finished, before.finished); + wire.disconnect(); await h.clock.advance(1); + assert.deepEqual(remaining.view().reveal, before.reveal); + assert.deepEqual(h.records.map(records => records.length), [1, 1]); + } finally { h.close(); } +}); + test('crossed explicit leave requests produce one draw per player instead of two wins', async () => { const h = setup(); try { @@ -84,7 +127,7 @@ test('crossed explicit leave requests produce one draw per player instead of two 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'); + await Promise.all(h.controllers.map(c => c.start())); await h.clock.advance(4000); 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); @@ -97,7 +140,7 @@ test('recovery starts a fresh epoch and ignores messages from the previous conne 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); + await Promise.all(h.controllers.map(c => c.start())); await h.clock.advance(4000); 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'); @@ -107,7 +150,7 @@ test('peers whose wall clocks differ by five seconds agree on deadline and timeo 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); + await Promise.all(h.controllers.map(c => c.start())); await h.clock.advance(4000); 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); @@ -130,3 +173,72 @@ test('refresh of a mutually confirmed final result saves without reconnecting to finally { restored.destroy(); } } finally { h.close(); } }); + +test('silent packet loss near a deadline enters recovery before declaring a protocol error', async () => { + const h = setup(); + try { + await Promise.all(h.controllers.map(c => c.start())); await h.clock.advance(4000); + await Promise.all(h.controllers.map(c => c.submitAction('charge'))); await h.clock.advance(1); await h.clock.advance(1200); + // Last heartbeat was just delivered; no RTC state event arrives when packets stop. + h.a.drop = h.b.drop = true; + await h.clock.advance(17000); + assert.ok(h.a.epoch > 0 && h.b.epoch > 0); + assert.deepEqual(h.records.map(r => r.map(x => [x.outcome, x.reason])), [[['aborted', 'connection_failed']], [['aborted', 'connection_failed']]]); + } finally { h.close(); } +}); + +test('transport send failure at selection deadline attempts recovery', async () => { + const h = setup(); + try { + await Promise.all(h.controllers.map(c => c.start())); await h.clock.advance(4000); + await h.clock.advance(2900); + h.a.connected = h.b.connected = false; + h.a.restart = h.b.restart = async () => { throw new Error('offline'); }; + await h.clock.advance(100); + assert.deepEqual(h.records.map(r => r.map(x => x.reason)), [['connection_failed'], ['connection_failed']]); + } finally { h.close(); } +}); + +test('recovery blocks actions, ignores repeated disconnects and records interruption only once', async () => { + const h = setup(); + try { + await Promise.all(h.controllers.map(c => c.start())); await h.clock.advance(4000); + h.a.drop = h.b.drop = true; h.a.disconnect(); h.b.disconnect(); + await h.clock.advance(1); + assert.equal(await h.controllers[0].submitAction('charge'), false); + h.a.disconnect(); h.b.disconnect(); await h.clock.advance(1); + assert.equal(h.a.epoch, 1); assert.equal(h.b.epoch, 1); + await h.clock.advance(10000); + assert.deepEqual(h.records.map(r => r.length), [1, 1]); + assert.deepEqual(h.records.map(r => r[0].outcome), ['aborted', 'aborted']); + } finally { h.close(); } +}); + +test('leaving during unavailable recovery settles and records once', async () => { + const h = setup(); + try { + await Promise.all(h.controllers.map(c => c.start())); await h.clock.advance(4000); + h.a.drop = h.b.drop = true; h.a.disconnect(); h.b.disconnect(); await h.clock.advance(1); + const leaving = h.controllers[0].leave(); await h.clock.advance(3000); await leaving; + await h.clock.advance(10000); + assert.deepEqual(h.records.map(r => r.map(x => x.outcome)), [['aborted'], ['aborted']]); + } finally { h.close(); } +}); + +test('loss of result agreement cannot display or save a speculative winner during recovery', async () => { + const h = setup(); const [a, b] = h.controllers; + try { + await Promise.all(h.controllers.map(c => c.start())); await h.clock.advance(4000); + await a.submitAction('charge'); await b.submitAction('charge'); await h.clock.advance(1); await h.clock.advance(1200); + await a.submitAction('blast'); await b.submitAction('charge'); + h.a.intercept = h.b.intercept = message => message.type === 'turn:resolved'; + h.a.restart = h.b.restart = () => new Promise(() => {}); + await h.clock.advance(7000); + assert.deepEqual(h.controllers.map(c => c.view().finished), [undefined, undefined]); + assert.deepEqual(h.controllers.map(c => c.view().opponentPresent), [false, false]); + await Promise.all(h.controllers.map(c => c.retrySave())); + assert.deepEqual(h.records.map(r => r.length), [0, 0]); + await h.clock.advance(10000); + assert.deepEqual(h.records.map(r => r.map(x => x.outcome)), [['aborted'], ['aborted']]); + } finally { h.close(); } +}); diff --git a/tests/peer-browser-monitor.js b/tests/peer-browser-monitor.js index 28d8b0f..a99c7ab 100644 --- a/tests/peer-browser-monitor.js +++ b/tests/peer-browser-monitor.js @@ -1,12 +1,20 @@ // Diagnostic harness for real-browser testing; excluded from production bundles. (() => { const NativePeer = window.RTCPeerConnection, NativeSocket = window.WebSocket; - const trace = []; let sockets = 0, nextPeer = 0; + const trace = [], channels = new Set(); let sockets = 0, nextPeer = 0, dropPackets = false; document.addEventListener('DOMContentLoaded', () => { const controls = document.createElement('aside'); controls.style.cssText = 'position:fixed;right:0;top:0;z-index:100;background:white;color:black;padding:4px'; controls.innerHTML = ''; document.body.append(controls); + for (const [label, action] of [ + ['채널 종료', () => { for (const channel of channels) if (channel.readyState === 'open') channel.close(); }], + ['패킷 차단', () => { dropPackets = true; report('packets-blocked'); }], + ['패킷 복구', () => { dropPackets = false; report('packets-restored'); }], + ]) { + const button = document.createElement('button'); button.textContent = label; + button.onclick = action; controls.append(button); + } setInterval(() => { const mode = controls.querySelector('select').value; if (mode === 'none') return; @@ -36,6 +44,11 @@ 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) { + channels.add(channel); + channel.addEventListener('close', () => channels.delete(channel)); + const send = channel.send.bind(channel); + channel.send = data => { if (!dropPackets) send(data); }; + channel.addEventListener('message', event => { if (dropPackets) event.stopImmediatePropagation(); }, { capture: true }); 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 => { diff --git a/tests/ranked.test.ts b/tests/ranked.test.ts index 249e3ee..eba651f 100644 --- a/tests/ranked.test.ts +++ b/tests/ranked.test.ts @@ -1,10 +1,12 @@ +import { DEFAULT_GLOBAL_SETTINGS } from '../constants/global-settings.js'; 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 { MongoClient, type Db } 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 { RANKED_FINISHED_TTL_MS, RANKED_QUEUE_TTL_MS, RANKED_STATS_CACHE_LIMIT, RANKED_STATS_CACHE_TTL_MS, + RANKED_LEADERBOARD_CACHE_LIMIT, RANKED_LEADERBOARD_CACHE_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'; @@ -15,6 +17,30 @@ import { EQUIPMENT_IDS } from '../constants/cosmetics.js'; import { createRoomStore } from '../src/server/rooms.js'; const integration = { skip: !process.env.TEST_MONGODB_URI }; +function observeResultReads(db: Db, afterRead: (filter?: Record) => Promise) { + const records = db.collection('ranked_matches'); + return new Proxy(db, { get(target, key) { + if (key === 'collection') return (name: string) => name !== 'ranked_matches' ? target.collection(name) : new Proxy(records, { + get(collection, method) { + if (method === 'find') return (filter?: Record) => { + const cursor = collection.find(filter ?? {}); + const iterate = cursor[Symbol.asyncIterator].bind(cursor); + cursor[Symbol.asyncIterator] = async function* () { + const snapshot = []; + for await (const result of iterate()) snapshot.push(result); + await afterRead(filter); + yield* snapshot; + }; + 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; + } }); +} 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('-', '')}`); @@ -324,7 +350,7 @@ test('server hides choices, checks costs and deadlines, resolves once and awards 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 clock.advance(DEFAULT_GLOBAL_SETTINGS.matchingDelayMs + DEFAULT_GLOBAL_SETTINGS.matchSuccessDelayMs + 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'); @@ -333,13 +359,13 @@ test('server hides choices, checks costs and deadlines, resolves once and awards 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); + const resolved = await s.action('player-b', b.gameSessionId, id, 1, 'charge'); + assert.equal(resolved.match?.phase, 'revealing'); 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); + const finished = await s.action('player-b', b.gameSessionId, id, 2, 'charge'); + assert.equal(finished.match?.phase, 'finished'); 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'); @@ -366,7 +392,7 @@ test('finished matches retain results on reload then expire when abandoned', int 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); + await f.clock.advance(DEFAULT_GLOBAL_SETTINGS.matchingDelayMs + DEFAULT_GLOBAL_SETTINGS.matchSuccessDelayMs + 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); @@ -406,12 +432,14 @@ test('deadline rejects late actions and duplicate terminal writes survive an amb 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 f.clock.advance(DEFAULT_GLOBAL_SETTINGS.matchingDelayMs + DEFAULT_GLOBAL_SETTINGS.matchSuccessDelayMs + MATCH_COUNTDOWN_MS); await s.action('player-a', f.a.gameSessionId, id, 1, 'guard'); + assert.equal((await s.leaderboard('player-a')).self, undefined); // 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); + assert.equal((await s.leaderboard('player-a')).self?.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); @@ -457,7 +485,7 @@ test('timeouts progress without polling, unlock colors only from authoritative r 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 clock.advance(DEFAULT_GLOBAL_SETTINGS.matchingDelayMs + DEFAULT_GLOBAL_SETTINGS.matchSuccessDelayMs + 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'); @@ -536,7 +564,7 @@ test('a slow leaderboard scan does not block match actions or deadline processin 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 f.clock.advance(DEFAULT_GLOBAL_SETTINGS.matchingDelayMs + DEFAULT_GLOBAL_SETTINGS.matchSuccessDelayMs + 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; @@ -545,3 +573,195 @@ test('a slow leaderboard scan does not block match actions or deadline processin assert.equal(progressed, true, 'leaderboard must not own the gameplay operation lock'); } finally { release(); await s.close(); await f.close(); } }); + +test('ranked polls reuse stats and concurrent leaderboard requests share a scan until a result is saved', integration, async () => { + const f = await fixture(); + await f.service.close(); + const records = f.db.collection('ranked_matches'); + let histories = 0, boards = 0; + const db = new Proxy(f.db, { get(target, key) { + if (key === 'collection') return (name: string) => name !== 'ranked_matches' ? target.collection(name) : new Proxy(records, { + get(collection, method) { + if (method === 'find') return (filter?: Record) => { + const cursor = collection.find(filter ?? {}); + const iterate = cursor[Symbol.asyncIterator].bind(cursor); + cursor[Symbol.asyncIterator] = async function* () { + if (filter?.playerIds) histories++; else boards++; + 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 Promise.all(Array.from({ length: 8 }, () => s.profile('player-a'))); + for (let n = 0; n < 8; n++) await s.status('player-a', f.a.gameSessionId); + assert.equal(histories, 1); + await Promise.all(Array.from({ length: 8 }, () => s.leaderboard('player-a'))); + assert.equal(boards, 1); + 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); + const profile = await s.profile('player-a'); + assert.equal(profile.stats.wins, 1); + profile.stats.wins = 999; + assert.equal((await s.profile('player-a')).stats.wins, 1); + const board = await s.leaderboard('player-a'); + assert.equal(board.self?.wins, 1); + board.entries[0].wins = 999; + assert.equal((await s.leaderboard('player-a')).self?.wins, 1); + assert.equal((await s.leaderboard('player-b')).self?.rank, 2); + assert.equal(boards, 2); + assert.equal(histories, 4); + f.clock.time += RANKED_LEADERBOARD_CACHE_TTL_MS; + await s.leaderboard('player-a'); + assert.equal(boards, 3); + assert.equal(histories, 4); + f.clock.time += RANKED_STATS_CACHE_TTL_MS; + await s.profile('player-a'); + assert.equal(histories, 5); + // Exceed the retained-player budget, then revisit the evicted player. + await Promise.all(Array.from({ length: RANKED_STATS_CACHE_LIMIT }, (_, n) => s.profile(`cache-player-${n}`))); + const before = histories; + await s.profile('player-a'); + assert.equal(histories, before + 1); + } finally { await s.close(); await f.close(); } +}); + +test('save during stalled ranked reads returns one snapshot without retrying or caching stale data', integration, async () => { + const f = await fixture(); + await f.service.close(); + let release!: () => void, entered!: () => void; + const gate = new Promise(resolve => { release = resolve; }); + const started = new Promise(resolve => { entered = resolve; }); + let hold = false, held = 0, histories = 0, boards = 0; + const db = observeResultReads(f.db, async filter => { + if (filter?.playerIds) histories++; else boards++; + if (hold && (filter === undefined || filter.playerIds === 'spectator')) { + if (++held === 2) entered(); + await gate; + } + }); + 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; + hold = true; + const board = s.leaderboard('player-a'), spectator = s.profile('spectator'); + await started; + await s.leave('player-b', f.b.gameSessionId, id); + const before = histories; + release(); + assert.equal((await board).entries.length, 0, 'overlapping request returns its snapshot'); + assert.equal((await spectator).stats.wins, 0); + assert.equal(boards, 1, 'a save must not restart the leaderboard scan'); + assert.equal(histories, before, 'unrelated saves must not restart personal history reads'); + hold = false; + await s.profile('spectator'); + assert.equal(histories, before, 'unrelated player retains its cache'); + assert.equal((await s.leaderboard('player-a')).self?.wins, 1); + assert.equal(boards, 2, 'overlapping stale snapshot must not be cached'); + assert.equal((await s.leaderboard('player-a')).self?.wins, 1); + assert.equal(boards, 2); + } finally { release(); await s.close(); await f.close(); } +}); + +test('bounded leaderboard cache preserves exact self ranks outside the top entries and recovers failed scans', integration, async () => { + const f = await fixture(); + await f.service.close(); + const count = RANKED_LEADERBOARD_CACHE_LIMIT + 2; + const players = Array.from({ length: count }, (_, n) => `rank-${String(n).padStart(4, '0')}`); + await f.db.collection('player_profiles').insertMany(players.map(_id => ({ _id, nameVersion: 1, displayName: _id }))); + await f.db.collection('ranked_matches').insertMany(players.map((id, order) => ({ + _id: `result-${id}`, playerIds: [id, 'opponent'], winnerIndex: 0, order, + }))); + let scans = 0, fail = true; + const db = observeResultReads(f.db, async filter => { + if (filter === undefined) { scans++; if (fail) throw new Error('scan failed'); } + }); + const s = await createRankedService(db, f.clock, 'test'); + try { + await assert.rejects(s.leaderboard(players.at(-1)!), /scan failed/); + fail = false; + const board = await s.leaderboard(players.at(-1)!); + assert.equal(board.entries.length, 100); + assert.equal(board.self?.rank, count); + assert.equal(board.self?.wins, 1); + assert.equal((await s.leaderboard(players.at(-1)!)).self?.rank, count); + assert.equal(scans, 2, 'failed promise is discarded and successful self rank is cached'); + // The requested tail entry evicted rank 1 from the bounded self cache. + assert.equal((await s.leaderboard(players[0])).self?.rank, 1); + assert.equal(scans, 3); + assert.equal((await s.leaderboard(players.at(-2)!)).self?.rank, count - 1); + assert.equal(scans, 4); + } finally { await s.close(); await f.close(); } +}); + +test('a personal history read overlapping its own result save cannot repopulate stale stats', integration, async () => { + const f = await fixture(); + await f.service.close(); + let release!: () => void, entered!: () => void, hold = false; + const gate = new Promise(resolve => { release = resolve; }); + const started = new Promise(resolve => { entered = resolve; }); + const db = observeResultReads(f.db, async filter => { + if (hold && filter?.playerIds === 'player-a') { hold = false; entered(); await gate; } + }); + 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; + // Expire the cached stats without running timers; leave settles the overdue draw. + f.clock.time += RANKED_STATS_CACHE_TTL_MS; + hold = true; + const old = s.profile('player-a'); + await started; + await s.leave('player-b', f.b.gameSessionId, id); + release(); + assert.equal((await old).stats.draws, 0); + assert.equal((await s.profile('player-a')).stats.draws, 1); + assert.equal((await s.profile('player-a')).stats.draws, 1); + } finally { release(); await s.close(); await f.close(); } +}); + +test('ranked timer reports only a safe failure code and retries persistence even if logging throws', integration, async () => { + const f = await fixture(); + await f.service.close(); + const records = f.db.collection('ranked_matches'); + let fail = true, reported!: () => void; + const logged = new Promise(resolve => { reported = resolve; }); + const codes: string[] = []; + 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 === 'updateOne') return async (...args: Parameters) => { + if (fail) { fail = false; throw new Error('mongodb://secret:password@host'); } + return collection.updateOne(...args); + }; + 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', code => { + codes.push(code); reported(); throw new Error('logger failed'); + }); + try { + await s.join('player-a', f.a.gameSessionId); + await s.join('player-b', f.b.gameSessionId); + await f.clock.advance(DEFAULT_GLOBAL_SETTINGS.matchingDelayMs + DEFAULT_GLOBAL_SETTINGS.matchSuccessDelayMs + MATCH_COUNTDOWN_MS + TURN_DURATION_MS); + await logged; + assert.deepEqual(codes, ['RANKED_BACKGROUND_FAILED']); + assert.equal(await records.countDocuments(), 0); + await f.clock.advance(1000); + assert.equal((await s.status('player-a', f.a.gameSessionId)).profile.stats.draws, 1); + assert.equal(await records.countDocuments(), 1); + } finally { await s.close(); await f.close(); } +}); diff --git a/tests/server.test.ts b/tests/server.test.ts index b02c21a..af48db6 100644 --- a/tests/server.test.ts +++ b/tests/server.test.ts @@ -16,6 +16,7 @@ test('HTTP game registration survives server restart without game timers and old try { const url = await app.listen({ host: '127.0.0.1', port: 0 }); assert.equal((await app.inject('/health')).statusCode, 200); + assert.equal((await app.inject('/ready')).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 }; @@ -37,3 +38,20 @@ test('HTTP game registration survives server restart without game timers and old 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(); } } }); + +test('production server enforces session limits before database creation', { skip: !uri }, async () => { + const databaseName = 'dragon_ball_test_limits_' + randomUUID().replaceAll('-', ''); + const app = await createGameServer({ mongoUri: uri!, databaseName, clientDirectory: 'dist/client', environment: 'test' }); + const client = await new MongoClient(uri!).connect(); + try { + for (let i = 0; i < 30; i++) { + assert.equal((await app.inject({ method: 'POST', url: '/api/game-sessions', payload: {} })).statusCode, 400); + } + const response = await app.inject({ method: 'POST', url: '/api/game-sessions', + payload: { createRequestId: randomBytes(32).toString('base64url') } }); + assert.equal(response.statusCode, 429); + assert.ok(Number(response.headers['retry-after']) > 0); + assert.equal(await client.db(databaseName).collection('players').countDocuments(), 0); + assert.equal(await client.db(databaseName).collection('game_sessions').countDocuments(), 0); + } finally { await app.close(); try { await client.db(databaseName).dropDatabase(); } finally { await client.close(); } } +}); diff --git a/tests/ui-scenarios.ts b/tests/ui-scenarios.ts index 740f601..39c6641 100644 --- a/tests/ui-scenarios.ts +++ b/tests/ui-scenarios.ts @@ -68,6 +68,14 @@ const scenarios: [string, () => void][] = [ }], ['패배', () => result(selfIndex === 0 ? 1 : 0)], ['승리', () => result(selfIndex)], ['무승부', () => result(null)], ['기 부족', () => { clearTimeout(timer); show({ ...state(), energy: [0, 0] }); }], + ['연결 복구 중', () => { clearTimeout(timer); show({ ...state(), opponentPresent: false }); }], + ['준비 중 연결 끊김', () => { clearTimeout(timer); show({ ...state(), phase: 'countdown', opponentPresent: false, startsAt: Date.now() + MATCH_COUNTDOWN_MS }); }], + ['연결 복구 실패', () => { + result(null); show({ ...current, aborted: true, finished: { ...current.finished!, reason: 'connection_failed' } }); + }], + ['중단 기록 저장 실패', () => { + result(null); show({ ...current, aborted: true, saveStatus: 'failed', finished: { ...current.finished!, reason: 'connection_failed', saved: false } }); + }], ['오른쪽 플레이어', () => { selfIndex = selfIndex === 0 ? 1 : 0; clearTimeout(timer); show(state()); }], ]; const picker = document.createElement('select'); picker.setAttribute('aria-label', '검증 상태');