Prepare game updates and single-process PM2 deployment

This commit is contained in:
2026-10-14 19:13:36 +09:00
parent 4f24129e29
commit 22792bc2b4
50 changed files with 1546 additions and 197 deletions
+10 -7
View File
@@ -1,13 +1,16 @@
# Fastify + Socket.IO use one application port. # Fastify + Socket.IO use one application port.
HOST=0.0.0.0 HOST=127.0.0.1
PORT=3000 PORT=2100
# Existing standalone MongoDB endpoint, verified with a read-only hello command. # Only trust the local reverse proxy. Leave empty for direct connections.
# If authentication is required, set credentials/authSource in your private environment. TRUST_PROXY=loopback
MONGODB_URI=mongodb://172.16.0.7:27017/?directConnection=true LOG_LEVEL=info
# Proposed application database name; the screenshot's "db" is a connection label. # Set credentials/authSource for your private MongoDB deployment.
MONGODB_DATABASE=dragon_ball 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. # Set a dedicated test endpoint before database integration tests.
# Tests must use a separate, isolated database and never reuse the application database. # Tests must use a separate, isolated database and never reuse the application database.
+14 -4
View File
@@ -4,6 +4,8 @@
## 실행 ## 실행
운영 서버의 Git clone·PM2·HTTPS 설정은 [배포 안내](docs/deployment.md)를 따른다.
Node.js 24 이상, MongoDB Standalone이 필요하다. Node.js 24 이상, MongoDB Standalone이 필요하다.
```powershell ```powershell
@@ -13,7 +15,7 @@ npm run build
npm start 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`로 감시 실행한다. 실행 중인 배포 폴더를 덮어쓰지 않고 새 빌드를 준비한 뒤 교체한다. Fastify HTTP API·페이지·Socket.IO `/signal`은 한 앱 포트와 MongoDB 연결 풀을 공유한다. 앱 다중 인스턴스의 시그널링 공유는 구현하지 않았다. 개발 중 클라이언트는 다시 빌드하고, 서버는 `npm run dev`로 감시 실행한다. 실행 중인 배포 폴더를 덮어쓰지 않고 새 빌드를 준비한 뒤 교체한다.
@@ -29,11 +31,19 @@ Fastify HTTP API·페이지·Socket.IO `/signal`은 한 앱 포트와 MongoDB
| 에네르기파 | 3 | 으·파·막기를 맞힘 | | 에네르기파 | 3 | 으·파·막기를 맞힘 |
| 순간이동 | 1 | 파·에네르기파 회피 | | 순간이동 | 1 | 파·에네르기파 회피 |
준비 3초 → 선택 4초 → 공개 1.2초. 기 0으로 시작하며 명중 한 번으로 종료한다. 첫 유효 선택을 잠그고 마감 전에 상대 선택을 공개하지 않는다. 같은 공격은 상쇄되며 공격·회피 비용은 실패해도 소모한다. 봇은 턴 시작 시점의 양쪽 기만 보고 미리 선택한다. 준비 3초 → 선택 최대 3초(기본값) → 공개 1.2초. 봇 대전은 내가 선택하면, 친구·랭킹 대전은 양쪽이 선택하면 즉시 진행한다. 기 0으로 시작하며 명중 한 번으로 종료한다. 첫 유효 선택을 잠그고 양쪽 선택이 확정되기 전에는 상대 선택을 공개하지 않는다. 같은 공격은 상쇄되며 공격·회피 비용은 실패해도 소모한다. 봇은 턴 시작 시점의 양쪽 기만 보고 미리 선택한다.
화면에는 내 기만 표시하고 행동 버튼은 내 캐릭터 아래에 배치한다. 상대 기는 화면에서만 숨긴다. P2P 판정·복원을 위해 양쪽 기가 브라우저에 보관되므로 개발자도구에서의 조회나 공개 행동을 통한 기 추적을 차단하지 않는다. 화면에는 내 기만 표시하고 행동 버튼은 내 캐릭터 아래에 배치한다. 상대 기는 화면에서만 숨긴다. 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 결과 저장은 복구 후 재시도한다. Socket.IO는 방 대기와 SDP/ICE 협상에만 사용하고 DataChannel 준비 확인 후 닫는다. 정상 대전 중 서버는 게임 객체·행동·판정 타이머를 유지하지 않는다. 서버 재시작 중에도 이미 연결된 대전은 진행하며 HTTP 결과 저장은 복구 후 재시도한다.
@@ -101,4 +111,4 @@ npm run build
## 후속 범위 ## 후속 범위
퀵매치·공개 랭킹·MMR·관전·채팅은 포함하지 않는다. 다중 앱 인스턴스, 분산 브라우저 1,500쌍, 운영 TURN 비용·실제 모바일 네트워크 검증은 별도 환경에서 확인한다. 친구전은 P2P, 랭킹 매칭·판정은 단일 서버 프로세스에서 실행한다. 서버 재시작 시 진행 중 랭킹 경기는 복원하지 않는다. MMR·관전·채팅은 포함하지 않는다. 다중 앱 인스턴스, 분산 브라우저 1,500쌍, 운영 TURN 비용·실제 모바일 네트워크 검증은 별도 환경에서 확인한다.
+5
View File
@@ -0,0 +1,5 @@
export const FOOTER_LINKS = [
{ id: 'rules', label: 'rule' },
{ id: 'privacy', label: 'privacy' },
{ id: 'contact', label: 'contact' },
] as const;
+4 -2
View File
@@ -1,3 +1,5 @@
import { DEFAULT_GLOBAL_SETTINGS } from './global-settings.js';
export const ACTIONS = { export const ACTIONS = {
charge: { name: '으', cost: 0, effect: '생존 시 기 +1', symbol: '✦' }, charge: { name: '으', cost: 0, effect: '생존 시 기 +1', symbol: '✦' },
blast: { name: '파', cost: 1, effect: '상대의 으를 맞힘', symbol: '➜' }, blast: { name: '파', cost: 1, effect: '상대의 으를 맞힘', symbol: '➜' },
@@ -7,8 +9,8 @@ export const ACTIONS = {
} as const; } as const;
export const ACTION_IDS = Object.keys(ACTIONS) as (keyof typeof ACTIONS)[]; export const ACTION_IDS = Object.keys(ACTIONS) as (keyof typeof ACTIONS)[];
export const MATCH_COUNTDOWN_MS = 3000; 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 REVEAL_DURATION_MS = 1200;
export const RULES_VERSION = '1'; export const RULES_VERSION = '2';
export const BOT_VERSION = '1'; export const BOT_VERSION = '1';
export const APP_VERSION = '0.1.0'; export const APP_VERSION = '0.1.0';
+4
View File
@@ -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 } };
+4
View File
@@ -3,6 +3,10 @@ export const RANKED_QUEUE_TTL_MS = 15000;
export const RANKED_FINISHED_TTL_MS = 15 * 60 * 1000; export const RANKED_FINISHED_TTL_MS = 15 * 60 * 1000;
export const RANKED_SAVE_RETRY_MS = 1000; export const RANKED_SAVE_RETRY_MS = 1000;
export const RANKED_LEADERBOARD_LIMIT = 100; 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_VERSION = 1;
export const PROFILE_NAME_PREFIXES = ['푸른', '밝은', '고요한', '빛나는', '온화한', '맑은', '은빛', '씩씩한'] as const; export const PROFILE_NAME_PREFIXES = ['푸른', '밝은', '고요한', '빛나는', '온화한', '맑은', '은빛', '씩씩한'] as const;
export const PROFILE_NAME_NOUNS = ['구름', '바람', '별빛', '노을', '하늘', '달빛', '물결', '나무'] as const; export const PROFILE_NAME_NOUNS = ['구름', '바람', '별빛', '노을', '하늘', '달빛', '물결', '나무'] as const;
+10
View File
@@ -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;
+48
View File
@@ -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;
}
}
+93
View File
@@ -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).
+21
View File
@@ -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' },
}],
};
+41
View File
@@ -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; }
}
+23
View File
@@ -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<FooterDialog, HTMLDialogElement>) {
container.classList.add('app-footer');
container.innerHTML = `<span id="connection" role="status">연결 중</span><span class="footer-divider" aria-hidden="true">|</span><nav aria-label="안내">${FOOTER_LINKS.map(({ id, label }) =>
`<button type="button" class="quiet" id="${id}-open" aria-haspopup="dialog" aria-controls="${id}-dialog">${label}</button>`).join('<span aria-hidden="true">|</span>')}</nav>`;
const status = container.querySelector<HTMLElement>('#connection')!;
for (const { id } of FOOTER_LINKS) {
container.querySelector<HTMLButtonElement>(`#${id}-open`)!.onclick = () => dialogs[id].showModal();
dialogs[id].querySelectorAll<HTMLButtonElement>('[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(); },
};
}
+15 -12
View File
@@ -3,6 +3,7 @@ import { ACTIONS } from '../../constants/gameplay.js';
import type { LocalMatchState } from '../shared/p2p-types.js'; import type { LocalMatchState } from '../shared/p2p-types.js';
import { appearanceKey, type FighterAppearance } from '../shared/cosmetics.js'; import { appearanceKey, type FighterAppearance } from '../shared/cosmetics.js';
import { finisherArt } from './fighter-art.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. // Survives view replacement on reconnect, without persisting anything to game state.
let presentation: { matchId: string; startedAt: number; dismissed: boolean } | undefined; 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<typeof setTimeout> | undefined; let dialog: HTMLDialogElement | undefined, timer: ReturnType<typeof setTimeout> | undefined;
let returnFocus: HTMLElement | undefined, previousBodyState: string | undefined; let returnFocus: HTMLElement | undefined, previousBodyState: string | undefined;
let activeMatch = '', resultAt = 0, artSignature = ''; let activeMatch = '', resultAt = 0, artSignature = '';
let timing: Record<keyof typeof CUTSCENE_TIMING, number> = { ...CUTSCENE_TIMING };
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)'); const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
function close(restoreFocus: boolean) { function close(restoreFocus: boolean) {
@@ -36,20 +38,18 @@ export function createFinishCutscene(onDismiss: () => void, appearances?: [Fight
if (!dialog || presentation?.matchId !== activeMatch) return; if (!dialog || presentation?.matchId !== activeMatch) return;
clearTimeout(timer); clearTimeout(timer);
const now = performance.now(), elapsed = now - presentation.startedAt; const now = performance.now(), elapsed = now - presentation.startedAt;
const complete = (reducedMotion.matches || elapsed >= CUTSCENE_TIMING.complete) && now >= resultAt; const complete = (reducedMotion.matches || elapsed >= timing.complete) && now >= resultAt;
const reactionEnds = dialog.dataset.attack === 'beam' ? CUTSCENE_TIMING.beamDissolve : CUTSCENE_TIMING.fallen; const reactionEnds = dialog.dataset.attack === 'beam' ? timing.beamDissolve : timing.fallen;
const stage = complete ? 'complete' : reducedMotion.matches || elapsed >= reactionEnds ? 'fallen' const stage = complete ? 'complete' : reducedMotion.matches || elapsed >= reactionEnds ? 'fallen'
: elapsed >= CUTSCENE_TIMING.fallen ? 'reaction' : elapsed >= timing.fallen ? 'reaction'
: elapsed >= CUTSCENE_TIMING.impact ? 'impact' : 'attack'; : elapsed >= timing.impact ? 'impact' : 'attack';
dialog.dataset.stage = stage; dialog.dataset.stage = stage;
const button = dialog.querySelector<HTMLButtonElement>('.cutscene-result')!;
button.hidden = !complete;
if (complete) { if (complete) {
if (document.activeElement === dialog) button.focus({ preventScroll: true }); dismiss();
} else { } else {
const next = reducedMotion.matches ? resultAt : Math.max(resultAt, presentation.startedAt + CUTSCENE_TIMING.complete); const next = reducedMotion.matches ? resultAt : Math.max(resultAt, presentation.startedAt + timing.complete);
const boundary = !reducedMotion.matches && elapsed < CUTSCENE_TIMING.impact ? presentation.startedAt + CUTSCENE_TIMING.impact const boundary = !reducedMotion.matches && elapsed < timing.impact ? presentation.startedAt + timing.impact
: !reducedMotion.matches && elapsed < CUTSCENE_TIMING.fallen ? presentation.startedAt + CUTSCENE_TIMING.fallen : !reducedMotion.matches && elapsed < timing.fallen ? presentation.startedAt + timing.fallen
: !reducedMotion.matches && elapsed < reactionEnds ? presentation.startedAt + reactionEnds : next; : !reducedMotion.matches && elapsed < reactionEnds ? presentation.startedAt + reactionEnds : next;
timer = setTimeout(advance, Math.max(0, boundary - now)); timer = setTimeout(advance, Math.max(0, boundary - now));
} }
@@ -75,6 +75,9 @@ export function createFinishCutscene(onDismiss: () => void, appearances?: [Fight
return; return;
} }
const now = performance.now(); 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) { if (presentation?.matchId !== state.matchId) {
presentation = { matchId: state.matchId, startedAt: now - Math.max(0, state.clockNow - reveal.revealedAt), dismissed: false }; 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; previousBodyState = document.body.dataset.cutscene;
const name = (index: number) => index === state.selfIndex ? '나' : state.mode === 'bot' ? '봇' : '상대'; const name = (index: number) => index === state.selfIndex ? '나' : state.mode === 'bot' ? '봇' : '상대';
dialog = document.createElement('dialog'); dialog = document.createElement('dialog');
dialog.style.setProperty('--cutscene-time-scale', String(1 / speed));
dialog.className = 'finish-cutscene'; dialog.tabIndex = -1; dialog.className = 'finish-cutscene'; dialog.tabIndex = -1;
dialog.dataset.hitIndex = String(hit); dialog.dataset.hitIndex = String(hit);
dialog.dataset.attack = reveal.actions[attacker]; dialog.dataset.attack = reveal.actions[attacker];
dialog.dataset.stage = 'attack'; dialog.dataset.stage = 'attack';
dialog.setAttribute('aria-label', `${name(attacker)}의 ${ACTIONS[reveal.actions[attacker]].name} 명중. ${hit === state.selfIndex ? '패배' : '승리'}.`); dialog.setAttribute('aria-label', `${name(attacker)}의 ${ACTIONS[reveal.actions[attacker]].name} 명중. ${hit === state.selfIndex ? '패배' : '승리'}.`);
dialog.innerHTML = `${finisherArt(hit, reveal.actions[attacker], chosenAppearances)}<button class="cutscene-result" hidden>결과 보기</button>`; dialog.innerHTML = finisherArt(hit, reveal.actions[attacker], chosenAppearances);
artSignature = signature; artSignature = signature;
dialog.querySelector<HTMLButtonElement>('.cutscene-result')!.onclick = dismiss;
dialog.addEventListener('cancel', event => { event.preventDefault(); dismiss(); }); dialog.addEventListener('cancel', event => { event.preventDefault(); dismiss(); });
document.body.append(dialog); document.body.append(dialog);
document.body.dataset.cutscene = 'active'; document.body.dataset.cutscene = 'active';
+33 -11
View File
@@ -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 { 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 { PEER_PING_MS, PEER_SILENCE_MS, PROTOCOL_TIMEOUT_MS, PROTOCOL_VERSION, RESUME_START_DELAY_MS } from '../../constants/webrtc.js';
import type { MatchEngine } from '../shared/match-engine.js'; import 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 { ActionId, Pair } from '../shared/types.js';
import type { GameClock } from './match-controller.js'; import type { GameClock } from './match-controller.js';
import type { PeerConnection } from './peer.js'; import type { PeerConnection } from './peer.js';
import { matchIntroductionDuration, matchPreparationDuration } from '../shared/match-introduction.js';
export type GamePeer = Pick<PeerConnection, 'connect' | 'connected' | 'epoch' | 'send' | 'onMessage' | 'onState' | 'restart' | 'close'>; export type GamePeer = Pick<PeerConnection, 'connect' | 'connected' | 'epoch' | 'send' | 'onMessage' | 'onState' | 'restart' | 'close'>;
interface FriendConfig { 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 stopped = false, recovering = false, offset = 0, localReady = false, peerReady = false, begun = false;
let confirmed = config.saved?.confirmed ?? engine.checkpoint(); let confirmed = config.saved?.confirmed ?? engine.checkpoint();
let exchange: TurnExchange | undefined, ownDigest = '', remoteDigest = '', remoteAck = '', sentAck = false, presented = false; let exchange: TurnExchange | undefined, ownDigest = '', remoteDigest = '', remoteAck = '', sentAck = false, presented = false;
let pendingTurnId: number | undefined, pendingCommit: Extract<PeerMessage, { type: 'turn:commit' }> | undefined;
let presentAt: number | undefined, privateChoice: { action: ActionId | null; nonce: string } | undefined; let presentAt: number | undefined, privateChoice: { action: ActionId | null; nonce: string } | undefined;
let resumedPeer = false, resumedAck = false, lastPeerAt = clock.now(), queue = Promise.resolve(); let resumedPeer = false, resumedAck = false, lastPeerAt = clock.now(), queue = Promise.resolve();
let leaveResolve: (() => void) | undefined; 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 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 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 clearAll() { for (const name of [...timers.keys()]) clear(name); }
function run(fn: () => unknown | Promise<unknown>) { void Promise.resolve().then(fn).catch(() => { if (!stopped) abort('protocol_error'); }); } function handleError() {
if (stopped) return;
if (!peer.connected) { void recover(); return; }
abort('protocol_error');
}
function run(fn: () => unknown | Promise<unknown>) { void Promise.resolve().then(fn).catch(handleError); }
function send(type: PeerMessage['type'], fields: Record<string, unknown> = {}) { function send(type: PeerMessage['type'], fields: Record<string, unknown> = {}) {
peer.send({ protocolVersion: PROTOCOL_VERSION, rulesVersion: RULES_VERSION, matchId: config.matchId, peer.send({ protocolVersion: PROTOCOL_VERSION, rulesVersion: RULES_VERSION, matchId: config.matchId,
connectionEpoch: peer.epoch, turnId: engine.view().turnId, type, ...fields } as PeerMessage); 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(); engine.abort(reason); config.changed(); config.finished();
stopped = true; peer.close(); leaveResolve?.(); 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 { function publicCheckpoint(): PublicCheckpoint {
const state = confirmed.state; const state = confirmed.state;
return { turnId: state.phase === 'revealing' ? state.turnId + 1 : Math.max(1, state.turnId), energy: [...state.energy], 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); else if (presentAt !== undefined) present(presentAt);
} }
async function resolved(actions: Pair<ActionId | null>) { async function resolved(actions: Pair<ActionId | null>) {
clear('deadline');
engine.resolve(actions); engine.resolve(actions);
const state = engine.view(); const state = engine.view();
ownDigest = await digestValue([config.matchId, peer.epoch, state.turnId, state.energy, actions, state.finished?.winnerIndex ?? null, state.finished?.reason ?? null]); 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(); send('turn:resolved', { digest: ownDigest }); agree();
} }
function selection() { function selection() {
const earlyCommit = pendingCommit; pendingTurnId = undefined; pendingCommit = undefined;
privateChoice = undefined; ownDigest = remoteDigest = remoteAck = ''; sentAck = false; presented = false; presentAt = undefined; privateChoice = undefined; ownDigest = remoteDigest = remoteAck = ''; sentAck = false; presented = false; presentAt = undefined;
const state = engine.view(); const state = engine.view();
exchange = createTurnExchange({ matchId: config.matchId, epoch: peer.epoch, turnId: state.turnId, slot: config.slot, energy: state.energy, 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)), 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(); 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) { function applyStart(startsAt: number, resume = false) {
if (!Number.isFinite(startsAt)) { abort('protocol_error'); return; } if (!Number.isFinite(startsAt)) { abort('protocol_error'); return; }
if (resume) { if (resume) {
engine.resumeTurn(confirmed, startsAt); recovering = false; clear('recovery'); engine.presence(true); selection(); heartbeat(); return; 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()); later('start', () => { if (engine.beginTurn(startsAt)) selection(); }, startsAt - now());
} }
function startTurn(startsAt: number, resume = false) { function startTurn(startsAt: number, resume = false) {
@@ -118,7 +130,7 @@ export function createFriendMatch(config: FriendConfig) {
send('turn:start', { startsAt, resume, turnId }); applyStart(startsAt, resume); send('turn:start', { startsAt, resume, turnId }); applyStart(startsAt, resume);
} }
function maybeStart() { 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() { function maybeResume() {
if (!recovering || !resumedPeer || !resumedAck) return; if (!recovering || !resumedPeer || !resumedAck) return;
@@ -129,9 +141,11 @@ export function createFriendMatch(config: FriendConfig) {
if (stopped || recovering) return; if (stopped || recovering) return;
if (confirmed.state.phase === 'finished') { if (confirmed.state.phase === 'finished') {
clearAll(); if (engine.view().phase !== 'finished') engine.restore(confirmed); 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(); engine.presence(false); config.changed();
later('recovery', () => abort('connection_failed'), RECONNECT_GRACE_MS - (clock.now() - disconnectedAt)); later('recovery', () => abort('connection_failed'), RECONNECT_GRACE_MS - (clock.now() - disconnectedAt));
try { try {
@@ -165,13 +179,20 @@ export function createFriendMatch(config: FriendConfig) {
} }
if (message.type === 'match:leave-ack') { leaveResolve?.(); return; } if (message.type === 'match:leave-ack') { leaveResolve?.(); return; }
if (message.type === 'match:rematch') { engine.requestRematch(config.slot === 0 ? 1 : 0); config.changed(); return; } if (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: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(); } 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: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(); } 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 => { const offState = peer.onState(state => {
if (state === 'disconnected' || state === 'failed') { const disconnectedAt = clock.now(); void queue.then(() => recover(disconnectedAt)); } 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'); } catch { abort('connection_failed'); }
}, },
get recovering() { return recovering; }, 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(); }, requestRematch() { engine.requestRematch(config.slot); if (peer.connected) send('match:rematch'); config.changed(); },
snapshot: () => ({ confirmed: structuredClone(confirmed), epoch: peer.epoch, privateChoice }), snapshot: () => ({ confirmed: structuredClone(confirmed), epoch: peer.epoch, privateChoice }),
async leave() { async leave() {
+9 -12
View File
@@ -95,13 +95,10 @@ dialog.finish-cutscene::backdrop{background:#080a0a;backdrop-filter:none}
.rock-fallen-art{overflow:visible} .rock-fallen-art{overflow:visible}
/* Stage changes reveal additional panels; previously revealed panels stay on the page. */ /* 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-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=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 .22s 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 .4s 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 .35s 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}
.finish-cutscene .cutscene-result{position:absolute;right:max(16px,env(safe-area-inset-right));bottom:max(14px,env(safe-area-inset-bottom));z-index:10;min-height:44px;padding:9px 17px;border:1px solid #d5cfbb;border-radius:3px;background:#141a18;color:#fff7e4;font-size:13px;line-height:1.2;box-shadow:2px 3px 0 #05070780}
.finish-cutscene .cutscene-result:hover{background:#303c35;border-color:#fff5db;transform:none}
.finish-cutscene .cutscene-result:focus-visible{outline:3px solid #fff6cd;outline-offset:4px}
@keyframes cutscene-enter{from{opacity:0;translate:0 14px}to{opacity:1;translate:0 0}} @keyframes cutscene-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)}} @keyframes cutscene-hit{from{transform:translate(-6px,-3px) rotate(-12deg)}to{transform:translate(-23px,-10px) rotate(-35deg)}}
@media(max-width:520px){ @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{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[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] .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{background:#080a0a;animation:none;translate:none;opacity:1}
.finish-cutscene[data-attack=beam] .beam-aftermath:after{opacity:.16} .finish-cutscene[data-attack=beam] .beam-aftermath:after{opacity:.16}
.finish-cutscene[data-attack=beam] .beam-reaction{position:absolute;inset:0 0 0 42%;overflow:hidden;clip-path:polygon(24% 0,100% 0,100% 100%,0 100%);background:#fcffff;visibility:hidden} .finish-cutscene[data-attack=beam] .beam-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] .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=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]: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-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 .55s 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 .55s 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 .55s 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-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-erode{from{transform:translateX(22px)}to{transform:translateX(0)}}
@keyframes beam-cutscene-irradiate{from{opacity:.2}to{opacity:.85}} @keyframes beam-cutscene-irradiate{from{opacity:.2}to{opacity:.85}}
+26
View File
@@ -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<Storage, 'getItem' | 'setItem'>, send: typeof fetch = globalThis.fetch.bind(globalThis)) {
let current: GlobalSettings | undefined, pending: Promise<GlobalSettings> | 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<GlobalSettings> {
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;
},
};
}
+2 -2
View File
@@ -17,9 +17,9 @@
<p id="notice" class="notice" role="status" aria-live="polite"></p> <p id="notice" class="notice" role="status" aria-live="polite"></p>
<button id="reconnect" class="primary" hidden>다시 시도</button> <button id="reconnect" class="primary" hidden>다시 시도</button>
</main> </main>
<footer class="app-footer"><span id="connection" role="status">연결 중</span><nav aria-label="안내"><button type="button" class="quiet" id="rules-open" aria-haspopup="dialog" aria-controls="rules-dialog">rule</button><span aria-hidden="true">|</span><button type="button" class="quiet" id="privacy-open" aria-haspopup="dialog" aria-controls="privacy-dialog">privacy</button><span aria-hidden="true">|</span><button type="button" class="quiet" id="contact-open" aria-haspopup="dialog" aria-controls="contact-dialog">contact</button></nav></footer> <footer id="app-footer" class="app-footer"></footer>
</div> </div>
<dialog id="rules-dialog"><div class="dialog-top"><h2>규칙</h2><button class="quiet" data-close aria-label="규칙 닫기">닫기</button></div><p>기 0으로 시작합니다. 공격에 한 번 맞으면 종료됩니다.</p><div id="rules-list"></div><p>선택 시간 4초. 선택은 바꿀 수 없고 마감 때 함께 공개됩니다.</p><p>한쪽 미입력은 패배, 양쪽 미입력은 무승부입니다. 상대에게 나가기가 전달되면 기권 패배입니다.</p><p>친구 대전 연결이 끊기면 최대 10초간 복구합니다. 확정되지 않은 선택은 취소됩니다. 복구하지 못하면 전적 변경 없이 중단합니다.</p><p>같은 공격끼리는 상쇄됩니다. 공격과 순간이동은 실패해도 기를 소모합니다.</p><p>봇·친구 연승은 탭의 접속 세션별로 저장합니다. 같은 탭 새로고침은 복원되며, 저장 정보 없는 새 탭은 새 전적입니다.</p><p>랭킹은 최대 연승, 누적 승 순입니다. 무승부는 연승을 유지합니다. 랭킹 기록·캐릭터·도복은 같은 브라우저의 익명 방문자 정보에 저장됩니다.</p></dialog> <dialog id="rules-dialog"><div class="dialog-top"><h2>규칙</h2><button class="quiet" data-close aria-label="규칙 닫기">닫기</button></div><p>기 0으로 시작합니다. 공격에 한 번 맞으면 종료됩니다.</p><div id="rules-list"></div><p>선택은 바꿀 수 없습니다. 봇 대전은 선택 즉시, 사용자 간 대전은 양쪽 선택이 끝나면 함께 공개됩니다.</p><p>한쪽 미입력은 패배, 양쪽 미입력은 무승부입니다. 상대에게 나가기가 전달되면 기권 패배입니다.</p><p>친구 대전 연결이 끊기면 최대 10초간 복구합니다. 확정되지 않은 선택은 취소됩니다. 복구하지 못하면 전적 변경 없이 중단합니다.</p><p>같은 공격끼리는 상쇄됩니다. 공격과 순간이동은 실패해도 기를 소모합니다.</p><p>봇·친구 연승은 탭의 접속 세션별로 저장합니다. 같은 탭 새로고침은 복원되며, 저장 정보 없는 새 탭은 새 전적입니다.</p><p>랭킹은 최대 연승, 누적 승 순입니다. 무승부는 연승을 유지합니다. 랭킹 기록·캐릭터·도복은 같은 브라우저의 익명 방문자 정보에 저장됩니다.</p></dialog>
<dialog id="privacy-dialog"><div class="dialog-top"><h2>개인정보·분석 안내</h2><button class="quiet" data-close aria-label="개인정보·분석 안내 닫기">닫기</button></div><p>브라우저에 익명 방문자 토큰을, 탭에 전적 복원 정보를 저장합니다. 새 전적은 마지막 대전 등록·결과 저장부터 90일간 보관합니다.</p><p>익명 방문·화면 전환·메뉴 조작·대전 시작과 결과·기기 분류·유입 도메인을 수집합니다. 방문 분석은 90일간 보관하며, 과거 서버 판정 대전 기록은 계속 보관합니다.</p><p>자동으로 부여한 랭킹 이름·대전 결과·선택한 캐릭터와 도복은 계속 보관합니다. 랭킹 이름과 최대 연승·누적 승은 리더보드에 공개됩니다.</p><p>저장소를 삭제하면 이전 기록에 다시 연결할 수 없습니다.</p></dialog> <dialog id="privacy-dialog"><div class="dialog-top"><h2>개인정보·분석 안내</h2><button class="quiet" data-close aria-label="개인정보·분석 안내 닫기">닫기</button></div><p>브라우저에 익명 방문자 토큰을, 탭에 전적 복원 정보를 저장합니다. 새 전적은 마지막 대전 등록·결과 저장부터 90일간 보관합니다.</p><p>익명 방문·화면 전환·메뉴 조작·대전 시작과 결과·기기 분류·유입 도메인을 수집합니다. 방문 분석은 90일간 보관하며, 과거 서버 판정 대전 기록은 계속 보관합니다.</p><p>자동으로 부여한 랭킹 이름·대전 결과·선택한 캐릭터와 도복은 계속 보관합니다. 랭킹 이름과 최대 연승·누적 승은 리더보드에 공개됩니다.</p><p>저장소를 삭제하면 이전 기록에 다시 연결할 수 없습니다.</p></dialog>
<dialog id="contact-dialog" aria-labelledby="contact-title"><div class="dialog-top"><h2 id="contact-title">문의</h2><button type="button" class="quiet" data-close aria-label="문의 닫기">닫기</button></div><div id="contact-content"><a href="mailto:sunha321@gmail.com">sunha321@gmail.com</a></div></dialog> <dialog id="contact-dialog" aria-labelledby="contact-title"><div class="dialog-top"><h2 id="contact-title">문의</h2><button type="button" class="quiet" data-close aria-label="문의 닫기">닫기</button></div><div id="contact-content"><a href="mailto:sunha321@gmail.com">sunha321@gmail.com</a></div></dialog>
<script type="module" src="./main.ts"></script> <script type="module" src="./main.ts"></script>
+2
View File
@@ -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 th:nth-child(2), .leaderboard-content td:nth-child(2) { text-align: left; overflow-wrap: anywhere; }
.leaderboard-content .self-ranking { background: #ffdfae; } .leaderboard-content .self-ranking { background: #ffdfae; }
body[data-screen=ranked-queue] .app { min-height: 100svh; } 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) { @media(max-width:760px) {
#home .home-actions > button:not(.primary) { min-height: 54px; font-size: 23px; } #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; } #home .home-actions .split button { min-height: 54px; font-size: 20px; padding-inline: 12px; }
+9 -4
View File
@@ -9,7 +9,7 @@ import { fighterArt } from './fighter-art.js';
interface LobbyOptions { interface LobbyOptions {
api: GameApi; api: GameApi;
onQueue(): void; onQueue(message: string): void;
onMatch(state: LocalMatchState): void; onMatch(state: LocalMatchState): void;
onHome(): void; onHome(): void;
onAppearance(appearance: FighterAppearance): void; onAppearance(appearance: FighterAppearance): void;
@@ -29,7 +29,7 @@ export function createLobbyFeatures(options: LobbyOptions) {
tools.innerHTML = '<button id="leaderboard-open" disabled>리더보드</button><button id="appearance-open" disabled>캐릭터·장비</button>'; tools.innerHTML = '<button id="leaderboard-open" disabled>리더보드</button><button id="appearance-open" disabled>캐릭터·장비</button>';
actions.after(tools); actions.after(tools);
const queue = document.createElement('section'); queue.id = 'ranked-queue'; queue.className = 'waiting ranked-queue'; queue.hidden = true; const queue = document.createElement('section'); queue.id = 'ranked-queue'; queue.className = 'waiting ranked-queue'; queue.hidden = true;
queue.innerHTML = '<div class="queue-fighter" aria-hidden="true"></div><h1>대전 상대 찾는 중</h1><button id="ranked-cancel">매칭 취소</button>'; queue.innerHTML = '<div class="queue-fighter" aria-hidden="true"></div><h1 role="status">랭킹대전 매칭 중…</h1><button id="ranked-cancel">매칭 취소</button>';
document.getElementById('waiting')!.after(queue); document.getElementById('waiting')!.after(queue);
const appearanceDialog = document.createElement('dialog'); appearanceDialog.id = 'appearance-dialog'; const appearanceDialog = document.createElement('dialog'); appearanceDialog.id = 'appearance-dialog';
appearanceDialog.innerHTML = '<div class="dialog-top"><h2>캐릭터·장비</h2><button class="quiet" aria-label="캐릭터·장비 닫기">닫기</button></div><div class="appearance-layout"><div class="appearance-preview" aria-label="외형 미리보기"></div><div class="appearance-options"><h3 id="skin-label">피부</h3><div class="skin-options" role="group" aria-labelledby="skin-label"></div><h3 id="hair-label">머리</h3><div class="hair-options" role="group" aria-labelledby="hair-label"></div><h3 id="color-label">도복 색상</h3><div class="color-options" role="group" aria-labelledby="color-label"></div><p class="color-condition" role="status"></p><h3 id="equipment-label">장비</h3><div class="equipment-options" role="group" aria-labelledby="equipment-label"></div><h3 id="eyewear-label" class="eyewear-heading">안경</h3><div class="eyewear-options" role="group" aria-labelledby="eyewear-label"></div><button class="equip primary">장착</button></div></div><p class="profile-notice" role="status"></p>'; appearanceDialog.innerHTML = '<div class="dialog-top"><h2>캐릭터·장비</h2><button class="quiet" aria-label="캐릭터·장비 닫기">닫기</button></div><div class="appearance-layout"><div class="appearance-preview" aria-label="외형 미리보기"></div><div class="appearance-options"><h3 id="skin-label">피부</h3><div class="skin-options" role="group" aria-labelledby="skin-label"></div><h3 id="hair-label">머리</h3><div class="hair-options" role="group" aria-labelledby="hair-label"></div><h3 id="color-label">도복 색상</h3><div class="color-options" role="group" aria-labelledby="color-label"></div><p class="color-condition" role="status"></p><h3 id="equipment-label">장비</h3><div class="equipment-options" role="group" aria-labelledby="equipment-label"></div><h3 id="eyewear-label" class="eyewear-heading">안경</h3><div class="eyewear-options" role="group" aria-labelledby="eyewear-label"></div><button class="equip primary">장착</button></div></div><p class="profile-notice" role="status"></p>';
@@ -169,12 +169,16 @@ export function createLobbyFeatures(options: LobbyOptions) {
content.append(table); content.append(table);
} catch (error) { content.textContent = error instanceof Error ? error.message : '랭킹을 불러오지 못했습니다.'; } } 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) { function consume(status: RankedStatus) {
if (status.match && latest?.match?.matchId === status.match.matchId && status.match.version < latest.match.version) return; if (status.match && latest?.match?.matchId === status.match.matchId && status.match.version < latest.match.version) return;
latest = status; updateProfile(status.profile); latest = status; updateProfile(status.profile);
active = status.status !== 'idle'; rankedStart.disabled = busy || active; active = status.status !== 'idle'; rankedStart.disabled = busy || active;
options.onAvailabilityChange(); options.onAvailabilityChange();
if (status.status === 'queued') options.onQueue(); if (status.status === 'queued') showQueue('랭킹대전 매칭 중…');
else if (status.match) options.onMatch(status.match); else if (status.match) options.onMatch(status.match);
else options.onHome(); else options.onHome();
} }
@@ -195,8 +199,9 @@ export function createLobbyFeatures(options: LobbyOptions) {
if (busy || active) return; if (busy || active) return;
busy = true; rankedStart.disabled = true; generation++; busy = true; rankedStart.disabled = true; generation++;
options.onAvailabilityChange(); options.onAvailabilityChange();
showQueue('랭킹대전 매칭 중…');
try { consume(await api.request<RankedStatus>('/api/ranked/queue', 'POST', {})); schedule(); } try { consume(await api.request<RankedStatus>('/api/ranked/queue', 'POST', {})); schedule(); }
catch (error) { options.onError(error); } catch (error) { options.onHome(); options.onError(error); }
finally { busy = false; options.onAvailabilityChange(); } finally { busy = false; options.onAvailabilityChange(); }
} }
async function leave() { async function leave() {
+50 -13
View File
@@ -15,13 +15,24 @@ import { createResultReporter, type SessionStorage } from './result-reporter.js'
import { createMatchView } from './match-view.js'; import { createMatchView } from './match-view.js';
import { fighterArt } from './fighter-art.js'; import { fighterArt } from './fighter-art.js';
import { createLobbyFeatures } from './lobby-features.js'; import { createLobbyFeatures } from './lobby-features.js';
import { createGlobalSettingsCache } from './global-settings.js';
import { createAppFooter } from './app-footer.js';
import './game.css'; import './game.css';
import './match.css'; import './match.css';
import './home.css'; import './home.css';
import './ui-theme.css'; import './ui-theme.css';
import './lobby-features.css'; import './lobby-features.css';
import './app-footer.css';
const el = <T extends HTMLElement = HTMLElement>(id: string) => document.getElementById(id)! as T; const el = <T extends HTMLElement = HTMLElement>(id: string) => document.getElementById(id)! as T;
const footer = createAppFooter(el('app-footer'), {
rules: el<HTMLDialogElement>('rules-dialog'), privacy: el<HTMLDialogElement>('privacy-dialog'), contact: el<HTMLDialogElement>('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; document.title = GAME_NAME;
const homeFighters = el('home-fighters'); const homeFighters = el('home-fighters');
homeFighters.innerHTML = '<div class="home-fighter home-fighter-left">' + fighterArt(0) + '</div><div class="home-fighter home-fighter-teleport">' + fighterArt(0, false, HOME_TELEPORT_APPEARANCE) + '</div><div class="home-fighter home-fighter-right">' + fighterArt(1) + '</div>'; homeFighters.innerHTML = '<div class="home-fighter home-fighter-left">' + fighterArt(0) + '</div><div class="home-fighter home-fighter-teleport">' + fighterArt(0, false, HOME_TELEPORT_APPEARANCE) + '</div><div class="home-fighter home-fighter-right">' + fighterArt(1) + '</div>';
@@ -46,10 +57,11 @@ const analytics = createClientAnalytics({
let reporter: ReturnType<typeof createResultReporter>; let reporter: ReturnType<typeof createResultReporter>;
let controller: MatchController | undefined, view: ReturnType<typeof createMatchView> | undefined; let controller: MatchController | undefined, view: ReturnType<typeof createMatchView> | undefined;
let currentMatch: LocalMatchState | undefined, currentRoom: PeerRoom | undefined, currentScreen = ''; let currentMatch: LocalMatchState | undefined, currentRoom: PeerRoom | undefined, currentScreen = '';
let matchReceivedAt = 0;
let waitingSocket: Socket | undefined, roomPoll: ReturnType<typeof setTimeout> | undefined; let waitingSocket: Socket | undefined, roomPoll: ReturnType<typeof setTimeout> | undefined;
let startingMatch: string | undefined, startedEvent: string | undefined, finishedEvent: string | undefined; let startingMatch: string | undefined, startedEvent: string | undefined, finishedEvent: string | undefined;
function notice(message = '') { el('notice').textContent = message; } 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() { function controls() {
['bot-start', 'room-create', 'join-open'].forEach(id => { el<HTMLButtonElement>(id).disabled = !ready || pending || !!lobby?.busy || !!lobby?.active; }); ['bot-start', 'room-create', 'join-open'].forEach(id => { el<HTMLButtonElement>(id).disabled = !ready || pending || !!lobby?.busy || !!lobby?.active; });
lobby?.setReady(ready && !pending); lobby?.setReady(ready && !pending);
@@ -94,10 +106,11 @@ function showMatch(state: LocalMatchState) {
state = { ...state, rematch: [...currentRoom.rematch], opponentLeft: currentRoom.status === 'closed' }; state = { ...state, rematch: [...currentRoom.rematch], opponentLeft: currentRoom.status === 'closed' };
} }
currentMatch = state; currentMatch = state;
matchReceivedAt = Date.now();
if (currentScreen !== 'result' || state.phase !== 'finished') setScreen('match'); 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), 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(() => 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); view.update(state);
if (state.finished?.saved && !state.ranked) stats(state.finished.stats); if (state.finished?.saved && !state.ranked) stats(state.finished.stats);
if (state.saveStatus === 'failed') el('reconnect').hidden = false; if (state.saveStatus === 'failed') el('reconnect').hidden = false;
@@ -105,9 +118,10 @@ function showMatch(state: LocalMatchState) {
connection('대전 종료', true); connection('대전 종료', true);
if (finishedEvent !== state.matchId && state.finished) { if (finishedEvent !== state.matchId && state.finished) {
finishedEvent = state.matchId; 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); 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) { async function launch(mode: Mode, room?: PeerRoom, existing?: ActiveMatch) {
const matchId = existing?.matchId ?? room?.currentMatchId ?? crypto.randomUUID(); const matchId = existing?.matchId ?? room?.currentMatchId ?? crypto.randomUUID();
@@ -115,7 +129,7 @@ async function launch(mode: Mode, room?: PeerRoom, existing?: ActiveMatch) {
startingMatch = matchId; stopWaiting(); startingMatch = matchId; stopWaiting();
let active = existing; let active = existing;
try { try {
const session = await api.sync(); const [session] = await Promise.all([api.sync(), globalSettings.refresh()]);
if (!active) { if (!active) {
if (session.activeMatch) { if (session.activeMatch) {
if (session.activeMatch.matchId !== matchId) throw new ApiClientError(409, 'MATCH_PENDING', '이전 대전 기록을 저장한 뒤 다시 시도하세요.'); if (session.activeMatch.matchId !== matchId) throw new ApiClientError(409, 'MATCH_PENDING', '이전 대전 기록을 저장한 뒤 다시 시도하세요.');
@@ -159,7 +173,7 @@ async function acceptRoom(room: PeerRoom) {
stopWaiting(); waitingSocket = signaling(room); stopWaiting(); waitingSocket = signaling(room);
waitingSocket.on('room:state', (next: PeerRoom) => { if (next.status !== 'waiting') void acceptRoom(next).catch(errorNotice); }); waitingSocket.on('room:state', (next: PeerRoom) => { if (next.status !== 'waiting') void acceptRoom(next).catch(errorNotice); });
waitingSocket.on('connect_error', 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(); waitingSocket.connect();
} }
async function rematch() { async function rematch() {
@@ -167,23 +181,48 @@ async function rematch() {
if (currentMatch.mode === 'bot') { storage.removeItem(GAME_CHECKPOINT_KEY); await launch('bot'); return; } if (currentMatch.mode === 'bot') { storage.removeItem(GAME_CHECKPOINT_KEY); await launch('bot'); return; }
if (!currentRoom) return; if (!currentRoom) return;
const room = currentRoom; const room = currentRoom;
const next = await api.request<PeerRoom>('/api/rooms/' + room.id + '/rematch', 'POST', { round: room.round, requestId: crypto.randomUUID() }); let next: PeerRoom;
try { next = await api.request<PeerRoom>('/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<PeerRoom>('/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); rememberRoom(next);
if (currentMatch) showMatch(currentMatch); refreshRoomMatch();
if (next.round > room.round) { storage.removeItem(GAME_CHECKPOINT_KEY); await launch('friend', next); return; } if (next.round > room.round) { storage.removeItem(GAME_CHECKPOINT_KEY); await launch('friend', next); return; }
watchRematch(room); watchRematch(room);
} }
function refreshRoomMatch() {
if (currentMatch) showMatch({ ...currentMatch, clockNow: currentMatch.clockNow + Date.now() - matchReceivedAt });
}
function watchRematch(room: PeerRoom) { function watchRematch(room: PeerRoom) {
clearTimeout(roomPoll); clearTimeout(roomPoll);
const poll = async () => { const poll = async () => {
if (currentRoom?.id !== room.id) return; if (currentRoom?.id !== room.id) return;
try { try {
const updated = await api.request<PeerRoom>('/api/rooms/' + room.id); const updated = await api.request<PeerRoom>('/api/rooms/' + room.id);
if (currentRoom?.id !== room.id) return;
rememberRoom(updated); rememberRoom(updated);
if (currentMatch) showMatch(currentMatch); refreshRoomMatch();
if (updated.status === 'closed') return; if (updated.status === 'closed') return;
if (updated.round > room.round) { storage.removeItem(GAME_CHECKPOINT_KEY); await launch('friend', updated); 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);
}; };
roomPoll = setTimeout(() => { void poll(); }, ROOM_POLL_MS); roomPoll = setTimeout(() => { void poll(); }, ROOM_POLL_MS);
@@ -201,7 +240,7 @@ async function boot() {
try { try {
let token: string | undefined; let token: string | undefined;
try { token = localStorage.getItem(GUEST_TOKEN_KEY) ?? undefined; } catch { storageAvailable = false; } 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; } 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); reporter?.destroy(); reporter = createResultReporter({ gameSessionId: session.gameSessionId, record: value => api.record(value) }, storage);
analytics.ready(); stats(session.stats); connection('접속됨', true); el('reconnect').hidden = true; 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('copy-code').onclick = () => { void copy(false); }; el('copy-link').onclick = () => { void copy(true); };
el('rules-list').innerHTML = ACTION_IDS.map(id => '<div class="rule-row"><strong>' + ACTIONS[id].name + '</strong><span>기 ' + ACTIONS[id].cost + ' 소모 · ' + ACTIONS[id].effect + '</span></div>').join(''); el('rules-list').innerHTML = ACTION_IDS.map(id => '<div class="rule-row"><strong>' + ACTIONS[id].name + '</strong><span>기 ' + ACTIONS[id].cost + ' 소모 · ' + ACTIONS[id].effect + '</span></div>').join('');
for (const name of ['rules', 'privacy', 'contact']) el(name + '-open').onclick = () => el<HTMLDialogElement>(name + '-dialog').showModal();
document.querySelectorAll<HTMLButtonElement>('[data-close]').forEach(b => { b.onclick = () => b.closest('dialog')!.close(); });
const invitation = new URLSearchParams(location.search).get('room'); const invitation = new URLSearchParams(location.search).get('room');
if (invitation && /^[A-Z0-9]{6}$/i.test(invitation)) el<HTMLInputElement>('room-code').value = invitation.toUpperCase(); if (invitation && /^[A-Z0-9]{6}$/i.test(invitation)) el<HTMLInputElement>('room-code').value = invitation.toUpperCase();
lobby = createLobbyFeatures({ lobby = createLobbyFeatures({
api, api,
onQueue: () => { setScreen('ranked-queue'); connection('상대 찾는 중', true); }, onQueue: message => { setScreen('ranked-queue'); connection(message, true); },
onMatch: state => showMatch(state), onMatch: state => showMatch(state),
onHome: () => { setScreen('home'); connection('접속됨', true); notice(); el('reconnect').hidden = true; }, onHome: () => { setScreen('home'); connection('접속됨', true); notice(); el('reconnect').hidden = true; },
onAppearance: appearance => { onAppearance: appearance => {
+7 -2
View File
@@ -44,7 +44,7 @@ export function createMatchController(config: ControllerConfig) {
if (state.phase === 'countdown' && clock.now() >= state.startsAt) engine.beginTurn(state.startsAt); 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); else if (state.phase === 'revealing' && clock.now() >= state.reveal!.revealEndsAt) engine.beginTurn(state.reveal!.revealEndsAt);
state = engine.view(); 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(); publish(); state = engine.view();
if (state.phase === 'finished') { void record(); return; } if (state.phase === 'finished') { void record(); return; }
const next = state.phase === 'countdown' ? state.startsAt : state.phase === 'selecting' ? state.deadline : state.reveal!.revealEndsAt; 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(); if (!saved) engine.start();
tick(); 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() { async leave() {
if (friend) { await friend.leave(); await record(); return; } if (friend) { await friend.leave(); await record(); return; }
clock.clearTimeout(timer); clock.clearTimeout(timer);
+30 -12
View File
@@ -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 { ActionId } from '../shared/types.js';
import type { LocalMatchState } from '../shared/p2p-types.js'; import type { LocalMatchState } from '../shared/p2p-types.js';
import { appearanceKey, type FighterAppearance } from '../shared/cosmetics.js'; import { appearanceKey, type FighterAppearance } from '../shared/cosmetics.js';
import { createActionRenderer } from './action-renderer.js'; import { createActionRenderer } from './action-renderer.js';
import { actionIcon, ashPileArt, checkIcon, fighterArt } from './fighter-art.js'; import { actionIcon, ashPileArt, checkIcon, fighterArt } from './fighter-art.js';
import { createFinishCutscene } from './finish-cutscene.js'; import { createFinishCutscene } from './finish-cutscene.js';
import { matchIntroductionMessage } from '../shared/match-introduction.js';
const fighter = (i: number, appearance?: FighterAppearance) => `<div class="fighter" data-fighter="${i}"> const fighter = (i: number, appearance?: FighterAppearance) => `<div class="fighter" data-fighter="${i}">
<div class="avatar"><div class="aura"></div><div class="motion-trails"><i></i><i></i><i></i></div><div class="character-direction">${fighterArt(i, false, appearance)}</div>${ashPileArt()}<div class="projectile"></div><div class="shield"></div><div class="impact"></div></div> <div class="avatar"><div class="aura"></div><div class="motion-trails"><i></i><i></i><i></i></div><div class="character-direction">${fighterArt(i, false, appearance)}</div>${ashPileArt()}<div class="projectile"></div><div class="shield"></div><div class="impact"></div></div>
@@ -12,7 +13,7 @@ const fighter = (i: number, appearance?: FighterAppearance) => `<div class="figh
</div>`; </div>`;
export function createMatchView(container: HTMLElement, onAction: (action: ActionId) => Promise<boolean>, onRematch: () => void, onLeave: () => void, onResult: () => void = () => {}, onRules: () => void = () => {}, appearances?: [FighterAppearance, FighterAppearance]) { export function createMatchView(container: HTMLElement, onAction: (action: ActionId) => Promise<boolean>, onRematch: () => void, onLeave: () => void, onResult: () => void = () => {}, onRules: () => void = () => {}, appearances?: [FighterAppearance, FighterAppearance]) {
container.innerHTML = `<div class="match-top"><div class="match-title"><span class="mode-label"></span><h1 class="turn-heading"></h1></div><details class="match-menu"><summary aria-label="대전 메뉴">메뉴</summary><div class="match-menu-items"><button class="match-rules quiet">규칙</button><button class="leave quiet">나가기</button></div></details></div> container.innerHTML = `<section class="match-introduction waiting" hidden><div class="queue-fighter" aria-hidden="true"></div><h1 role="status"></h1></section><div class="match-top"><div class="match-title"><span class="mode-label"></span><h1 class="turn-heading"></h1></div><details class="match-menu"><summary aria-label="대전 메뉴">메뉴</summary><div class="match-menu-items"><button class="match-rules quiet">규칙</button><button class="leave quiet">나가기</button></div></details></div>
<div class="timing"><span class="selection-status" role="status"></span><span class="time-label" aria-label="남은 시간"></span><div class="time-track" role="progressbar" aria-label="남은 선택 시간" aria-valuemin="0" aria-valuemax="100"><span></span></div></div> <div class="timing"><span class="selection-status" role="status"></span><span class="time-label" aria-label="남은 시간"></span><div class="time-track" role="progressbar" aria-label="남은 선택 시간" aria-valuemin="0" aria-valuemax="100"><span></span></div></div>
<div class="arena"><div class="arena-floor" aria-hidden="true"></div><div class="result-banner" hidden><h2></h2><p class="finish-reason"></p></div>${fighter(0, appearances?.[0])}<div class="duel-cue"><span class="versus" aria-hidden="true">VS</span><strong class="countdown-number" role="status"></strong></div><span class="clash-label" role="status"></span><div class="clash-burst" aria-hidden="true"></div>${fighter(1, appearances?.[1])}</div> <div class="arena"><div class="arena-floor" aria-hidden="true"></div><div class="result-banner" hidden><h2></h2><p class="finish-reason"></p></div>${fighter(0, appearances?.[0])}<div class="duel-cue"><span class="versus" aria-hidden="true">VS</span><strong class="countdown-number" role="status"></strong></div><span class="clash-label" role="status"></span><div class="clash-burst" aria-hidden="true"></div>${fighter(1, appearances?.[1])}</div>
<div class="player-controls" role="group" aria-label="내 행동 선택"><div class="energy">${actionIcon('charge')}<strong class="energy-count"></strong></div><div class="action-buttons">${ACTION_IDS.map(id => `<button data-action="${id}" aria-label="${ACTIONS[id].name}, ${ACTIONS[id].effect}${ACTIONS[id].cost ? `, 기 ${ACTIONS[id].cost} 소모` : ''}"><span class="action-symbol">${actionIcon(id)}</span><strong>${ACTIONS[id].name}</strong><small>${id === 'charge' || id === 'guard' ? ACTIONS[id].effect : `기 ${ACTIONS[id].cost} 소모`}</small><span class="chosen-mark">${checkIcon}</span></button>`).join('')}</div></div> <div class="player-controls" role="group" aria-label="내 행동 선택"><div class="energy">${actionIcon('charge')}<strong class="energy-count"></strong></div><div class="action-buttons">${ACTION_IDS.map(id => `<button data-action="${id}" aria-label="${ACTIONS[id].name}, ${ACTIONS[id].effect}${ACTIONS[id].cost ? `, 기 ${ACTIONS[id].cost} 소모` : ''}"><span class="action-symbol">${actionIcon(id)}</span><strong>${ACTIONS[id].name}</strong><small>${id === 'charge' || id === 'guard' ? ACTIONS[id].effect : `기 ${ACTIONS[id].cost} 소모`}</small><span class="chosen-mark">${checkIcon}</span></button>`).join('')}</div></div>
@@ -48,31 +49,39 @@ export function createMatchView(container: HTMLElement, onAction: (action: Actio
function tick() { function tick() {
if (!current) return; if (!current) return;
const now = Date.now() + offset, s = current; 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; 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; const phase = revealing ? 'revealing' : resultVisible ? 'finished' : s.phase;
container.dataset.phase = phase; container.dataset.phase = phase;
container.dataset.locked = String(!!s.ownAction); container.dataset.locked = String(!!s.ownAction);
const finishing = arena.dataset.outcome === 'hit'; const finishing = arena.dataset.outcome === 'hit';
el('.player-controls').hidden = finishing || resultVisible; el('.match-top').hidden = resultVisible || !!introduction;
el('.timing').hidden = finishing || resultVisible; el('.player-controls').hidden = finishing || resultVisible || !!introduction;
el('.timing').hidden = finishing || resultVisible || !!introduction;
el('.match-result').hidden = !resultVisible; el('.match-result').hidden = !resultVisible;
el('.result-banner').hidden = !resultVisible; el('.result-banner').hidden = !resultVisible;
el('.mode-label').textContent = s.ranked ? '랭킹 대전' : s.mode === 'bot' ? '봇 대전' : '친구 대전'; el('.mode-label').textContent = s.ranked ? '랭킹 대전' : s.mode === 'bot' ? '봇 대전' : '친구 대전';
el('.rematch').textContent = s.ranked ? '다시 매칭' : '다시 대전'; 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 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 count = Number.isFinite(remaining) ? String(Math.ceil(remaining / 1000)) : '';
const countdown = el('.countdown-number'); const countdown = el('.countdown-number');
if (s.phase === 'countdown' && countdown.textContent !== count) { countdown.textContent = count; countdown.dataset.count = count; } 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('.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)}초` : ''; el('.time-label').textContent = s.phase === 'selecting' && !revealing && !recovering ? `${(remaining / 1000).toFixed(1)}초` : '';
const progress = s.phase === 'countdown' ? remaining / MATCH_COUNTDOWN_MS : s.phase === 'selecting' ? remaining / TURN_DURATION_MS : 0; 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').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-valuenow', String(Math.round(Math.min(1, progress) * 100)));
el('.time-track').setAttribute('aria-label', s.phase === 'countdown' ? '대전 시작까지 남은 시간' : '남은 선택 시간'); 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 => { buttons.forEach(button => {
const id = button.dataset.action as ActionId; const id = button.dataset.action as ActionId;
const affordable = s.energy[s.selfIndex] >= ACTIONS[id].cost; 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 (resultVisible && s.finished) {
if (resultReported !== s.matchId) { resultReported = s.matchId; onResult(); } if (resultReported !== s.matchId) { resultReported = s.matchId; onResult(); }
const f = s.finished; 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'; const outcome = s.aborted ? 'aborted' : f.winnerIndex === null ? 'draw' : f.winnerIndex === s.selfIndex ? 'win' : 'loss';
container.dataset.outcome = outcome; container.dataset.outcome = outcome;
arena.dataset.result = 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('.result-stats').textContent = f.saved ? `현재 연승 ${stats.current} · 최고 연승 ${stats.best}` : '';
el('.save-status').textContent = s.saveStatus === 'failed' ? '기록을 저장하지 못했습니다. 다시 시도하세요.' : f.saved ? '' : '기록 저장 중'; el('.save-status').textContent = s.saveStatus === 'failed' ? '기록을 저장하지 못했습니다. 다시 시도하세요.' : f.saved ? '' : '기록 저장 중';
el<HTMLButtonElement>('.rematch').disabled = !f.saved || (!s.ranked && (!!s.rematch[s.selfIndex] || s.opponentLeft)); el<HTMLButtonElement>('.rematch').disabled = !f.saved || (!s.ranked && (!!s.rematch[s.selfIndex] || s.opponentLeft));
el('.rematch-status').textContent = s.ranked ? '' : s.opponentLeft ? '상대가 나갔습니다.' : s.rematch[s.selfIndex] ? '상대의 재대전 동의 대기' : s.rematch[s.selfIndex === 0 ? 1 : 0] ? '상대가 재대전을 요청했습니다.' : ''; 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 { } else {
delete arena.dataset.result; delete arena.dataset.result;
if (arena.dataset.outcome) container.dataset.outcome = arena.dataset.outcome; if (arena.dataset.outcome) container.dataset.outcome = arena.dataset.outcome;
@@ -116,7 +128,10 @@ export function createMatchView(container: HTMLElement, onAction: (action: Actio
return { return {
update(state: LocalMatchState) { update(state: LocalMatchState) {
if (current?.matchId === state.matchId && current.version > state.version) return; 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++; } if (current && current.turnId !== state.turnId) { busy = false; submission++; }
current = state; offset = state.clockNow - Date.now(); current = state; offset = state.clockNow - Date.now();
container.dataset.self = String(state.selfIndex); container.dataset.self = String(state.selfIndex);
@@ -133,8 +148,11 @@ export function createMatchView(container: HTMLElement, onAction: (action: Actio
node.setAttribute('role', 'group'); node.setAttribute('role', 'group');
node.setAttribute('aria-label', i === state.selfIndex ? '내 캐릭터' : state.mode === 'bot' ? '봇 캐릭터' : '상대 캐릭터'); node.setAttribute('aria-label', i === state.selfIndex ? '내 캐릭터' : state.mode === 'bot' ? '봇 캐릭터' : '상대 캐릭터');
}); });
// 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); if (state.reveal && !state.aborted && (!state.finished || state.finished.reason === 'hit')) renderer.play(state.reveal, Date.now() + offset);
else renderer.reset(); else renderer.reset();
}
tick(); tick();
cutscene.update(state); cutscene.update(state);
}, destroy() { submission++; clearInterval(interval); cutscene.destroy(); renderer.destroy(); container.innerHTML = ''; current = undefined; }, }, destroy() { submission++; clearInterval(interval); cutscene.destroy(); renderer.destroy(); container.innerHTML = ''; current = undefined; },
-33
View File
@@ -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 { 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; } 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) { body.player-app dialog:not(.finish-cutscene) {
padding: 24px; padding: 24px;
border: 2px solid var(--ink); 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; } body.player-app #contact-content { overflow-wrap: anywhere; }
@media (max-width: 760px) { @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 { 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:not(.finish-cutscene) { padding: 18px; }
body.player-app .dialog-top h2 { font-size: 19px; } body.player-app .dialog-top h2 { font-size: 19px; }
} }
+7 -1
View File
@@ -5,7 +5,9 @@ import { getAnalyticsReport, validateFilters } from './analytics-report.js';
import type { StatsStore } from './stats-store.js'; import type { StatsStore } from './stats-store.js';
import type { Clock } from './clock.js'; import type { Clock } from './clock.js';
import { RETENTION_MS } from '../../constants/analytics.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 digest = (value: string) => createHash('sha256').update(value).digest();
const authorized = (header: string | undefined) => { const authorized = (header: string | undefined) => {
if (!credentials.user || !credentials.password) return false; 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: '관리자 인증이 필요합니다.' }); if (!authorized(request.headers.authorization)) return reply.header('WWW-Authenticate', 'Basic realm="analytics", charset="UTF-8"').code(401).send({ error: '관리자 인증이 필요합니다.' });
}); });
admin.get('/admin/analytics', (_request, reply) => reply.sendFile('admin/analytics/index.html', directory)); admin.get('/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) => { admin.get('/api/admin/analytics/report', async (request, reply) => {
let filters; let filters;
try { filters = validateFilters(request.query as Record<string, unknown>, new Date(clock.now())); } try { filters = validateFilters(request.query as Record<string, unknown>, new Date(clock.now())); }
+15 -4
View File
@@ -14,15 +14,26 @@ import { registerAnalyticsApi, registerGameApi, registerRoomApi } from './game-a
import { registerSignaling, type RtcServerOptions } from './signaling.js'; import { registerSignaling, type RtcServerOptions } from './signaling.js';
import { createRankedService } from './ranked-service.js'; import { createRankedService } from './ranked-service.js';
import { registerRankedApi } from './ranked-api.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<FastifyInstance> { export async function createGameServer(options: ServerOptions): Promise<FastifyInstance> {
const store = await createStatsStore(options); 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 io = new Server(app.server, { maxHttpBufferSize: 16384 });
const clock = options.clock ?? systemClock; 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 gameSessions = await createGameSessionStore(store.db, () => clock.now(), options.environment ?? 'production');
const peerRooms = await createRoomStore(store.db, () => clock.now()); 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()); const signaling = registerSignaling(io, gameSessions, peerRooms, options.rtc, () => clock.now());
registerGameApi(app, gameSessions, store, ranked); registerGameApi(app, gameSessions, store, ranked);
registerRoomApi(app, gameSessions, peerRooms, signaling, ranked); registerRoomApi(app, gameSessions, peerRooms, signaling, ranked);
@@ -43,7 +54,7 @@ export async function createGameServer(options: ServerOptions): Promise<FastifyI
return reply.sendFile(`assets/${path}`); return reply.sendFile(`assets/${path}`);
}); });
} }
registerAdmin(app, store, analytics, clock, { user: options.adminUser, password: options.adminPassword }, directory); registerAdmin(app, store, analytics, clock, { user: options.adminUser, password: options.adminPassword }, directory, settings);
app.addHook('preClose', async () => { app.addHook('preClose', async () => {
await ranked.close(); await ranked.close();
await analytics.close(); await analytics.close();
+2
View File
@@ -9,6 +9,7 @@ import type { Analytics } from './analytics.js';
import { GameError } from './errors.js'; import { GameError } from './errors.js';
import { ANALYTICS_BATCH_SIZE } from '../../constants/analytics.js'; import { ANALYTICS_BATCH_SIZE } from '../../constants/analytics.js';
import type { RankedService } from './ranked-service.js'; import type { RankedService } from './ranked-service.js';
import { logSafeError } from './http-operations.js';
export function gameAuth(request: FastifyRequest): GameSessionAuth { export function gameAuth(request: FastifyRequest): GameSessionAuth {
const token = request.headers.authorization?.match(/^Bearer ([A-Za-z0-9_-]{43})$/)?.[1]; 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) { catch (error) {
const code = error instanceof ApiError || error instanceof GameError ? error.code : 'SERVER_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; 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] ?? '요청을 확인하고 다시 시도하세요.' }); return reply.code(status).send({ ok: false, code, message: GAME_API_MESSAGES[code] ?? '요청을 확인하고 다시 시도하세요.' });
} }
} }); } });
+5 -1
View File
@@ -6,6 +6,8 @@ import type { PlayerStats } from '../shared/types.js';
import { normalizeAppearance } from '../shared/cosmetics.js'; import { normalizeAppearance } from '../shared/cosmetics.js';
import type { FighterAppearance } from '../shared/cosmetics.js'; import type { FighterAppearance } from '../shared/cosmetics.js';
import type { Pair } from '../shared/types.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 { export class ApiError extends Error {
constructor(public statusCode: number, public code: string) { super(code); } 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<any>('player_profiles').findOne({ _id: before.playerId }) : null; const profile = before.playerId ? await db.collection<any>('player_profiles').findOne({ _id: before.playerId }) : null;
const ownAppearance = normalizeAppearance(profile?.appearance); const ownAppearance = normalizeAppearance(profile?.appearance);
let appearances: Pair<FighterAppearance> = [ownAppearance, { characterId: ownAppearance.characterId === 0 ? 1 : 0, colorId: 'white' }]; let appearances: Pair<FighterAppearance> = [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 (request.mode === 'friend') {
if (!validId(request.roomId)) throw new ApiError(400, 'INVALID_ROOM'); if (!validId(request.roomId)) throw new ApiError(400, 'INVALID_ROOM');
const room = await db.collection<any>('rooms').findOne({ _id: request.roomId, currentMatchId: request.matchId, const room = await db.collection<any>('rooms').findOne({ _id: request.roomId, currentMatchId: request.matchId,
status: 'paired', expiresAt: { $gt: new Date(now()) }, status: 'paired', expiresAt: { $gt: new Date(now()) },
$or: [{ hostGameSessionId: auth.gameSessionId }, { guestGameSessionId: auth.gameSessionId }] }); $or: [{ hostGameSessionId: auth.gameSessionId }, { guestGameSessionId: auth.gameSessionId }] });
if (!room) throw new ApiError(404, 'ROOM_NOT_FOUND'); if (!room) throw new ApiError(404, 'ROOM_NOT_FOUND');
settings = room.settings ?? { ...DEFAULT_GLOBAL_SETTINGS };
appearances = room.appearances ?? [normalizeAppearance(undefined), { characterId: 1, colorId: 'white' }]; appearances = room.appearances ?? [normalizeAppearance(undefined), { characterId: 1, colorId: 'white' }];
} else if (request.roomId !== undefined) throw new ApiError(400, 'INVALID_ROOM'); } else if (request.roomId !== undefined) throw new ApiError(400, 'INVALID_ROOM');
const match = { matchId: request.matchId, seq: request.expectedPreviousSeq + 1, mode: request.mode, 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 } }, { 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) }, $set: { activeMatch: match, updatedAt: new Date(now()), expiresAt: new Date(now() + GAME_SESSION_TTL_MS) },
}, { returnDocument: 'after' }); }, { returnDocument: 'after' });
+35
View File
@@ -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<GlobalSettings & { _id: string }>('global_settings');
async function get(): Promise<GlobalSettings> {
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<typeof createGlobalSettingsStore>;
+96
View File
@@ -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<FastifyBaseLogger, 'error'>, 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<unknown> } = {}) {
const now = options.now ?? Date.now;
const maxIps = options.maxIps ?? HTTP_LIMITS.maxIps;
const windows = new Map<string, { expires: number; api: number; sessions: number; admin: number }>();
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 });
}
});
}
+22 -6
View File
@@ -1,10 +1,26 @@
import { resolve } from 'node:path'; import { resolve } from 'node:path';
import { createGameServer } from './create-server.js'; 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; import { logSafeError, parseTrustProxy } from './http-operations.js';
if (!MONGODB_URI) throw new Error('MONGODB_URI 환경 변수가 필요합니다.'); 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 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, const startupLogger = { error: (data: unknown) => console.error(JSON.stringify({ level: 50, time: Date.now(), ...data as object })) };
let app: Awaited<ReturnType<typeof createGameServer>> | 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 } }); 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) }); let closing = false;
console.log(`http://${HOST}:${PORT}`); for (const signal of ['SIGINT', 'SIGTERM'] as const) process.once(signal, () => {
for (const signal of ['SIGINT', 'SIGTERM'] as const) process.once(signal, () => { void app.close(); }); 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);
}
+83 -19
View File
@@ -4,15 +4,18 @@ import { COSMETIC_COLORS, EQUIPMENT_IDS, EYEWEAR_IDS, HAIR_STYLES, SKIN_TONES }
import { import {
PROFILE_NAME_NOUNS, PROFILE_NAME_PREFIXES, PROFILE_NAME_SUFFIX_MAX, PROFILE_NAME_SUFFIX_MIN, PROFILE_NAME_VERSION, 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_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'; } from '../../constants/ranked.js';
import { availableColorIds, availableEquipmentIds, DEFAULT_APPEARANCE, normalizeAppearance, type FighterAppearance } from '../shared/cosmetics.js'; import { availableColorIds, availableEquipmentIds, DEFAULT_APPEARANCE, normalizeAppearance, type FighterAppearance } from '../shared/cosmetics.js';
import { createMatchEngine, type MatchEngine } from '../shared/match-engine.js'; import { createMatchEngine, type MatchEngine } from '../shared/match-engine.js';
import { emptyPlayerStats, type LocalEndReason } from '../shared/p2p-types.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 { isLegalAction } from '../shared/rules.js';
import type { ActionId, ModeStats, Pair } from '../shared/types.js'; import type { ActionId, ModeStats, Pair } from '../shared/types.js';
import { systemClock, type Clock } from './clock.js'; import { systemClock, type Clock } from './clock.js';
import { ApiError, validId } from './game-session-store.js'; import { ApiError, validId } from './game-session-store.js';
import { createGlobalSettingsStore } from './global-settings.js';
import { matchIntroductionDuration } from '../shared/match-introduction.js';
interface ProfileDocument { interface ProfileDocument {
_id: string; displayName: string; nameVersion?: number; appearance: FighterAppearance; environment: string; createdAt: Date; updatedAt: Date; _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. // 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<ProfileDocument>('player_profiles'); const profiles = db.collection<ProfileDocument>('player_profiles');
const results = db.collection<TerminalMatch>('ranked_matches'); const results = db.collection<TerminalMatch>('ranked_matches');
// Finish migrating every legacy name before any profile or leaderboard can be served. // 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<string, QueueEntry>(); const queue = new Map<string, QueueEntry>();
const byPlayer = new Map<string, RankedMatch>(); const byPlayer = new Map<string, RankedMatch>();
const matches = new Set<RankedMatch>(); const matches = new Set<RankedMatch>();
const statsCache = new Map<string, { expiresAt: number; value: Promise<ModeStats> }>();
let resultRevision = 0;
let leaderboardCache: { expiresAt: number; entries: LeaderboardEntry[];
selves: Map<string, LeaderboardEntry | undefined>; complete: boolean } | undefined;
let leaderboardRead: Promise<LeaderboardEntry[]> | undefined;
let tail: Promise<unknown> = Promise.resolve(), closed = false; let tail: Promise<unknown> = Promise.resolve(), closed = false;
function serial<T>(work: () => Promise<T>): Promise<T> { function serial<T>(work: () => Promise<T>): Promise<T> {
const next = tail.then(() => { 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); stats.wins++; stats.current++; stats.best = Math.max(stats.best, stats.current);
} else { stats.losses++; stats.current = 0; } } else { stats.losses++; stats.current = 0; }
} }
async function readStats(playerId: string): Promise<ModeStats> {
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<LeaderboardEntry[]> {
const revision = resultRevision, totals = new Map<string, ModeStats>();
for await (const result of results.find().sort({ order: 1, _id: 1 })) for (const id of result.playerIds) {
const stats = totals.get(id) ?? emptyStats(); addResult(stats, result, id); totals.set(id, stats);
}
const entries = (await profiles.find({ _id: { $in: [...totals.keys()] } }).toArray()).map(profile => ({ playerId: profile._id,
displayName: profile.displayName, wins: totals.get(profile._id)!.wins, 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<PlayerProfile> { async function readProfile(playerId: string): Promise<PlayerProfile> {
if (!validId(playerId)) throw new ApiError(400, 'INVALID_PROFILE'); if (!validId(playerId)) throw new ApiError(400, 'INVALID_PROFILE');
const now = new Date(clock.now()); 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, appearance: { ...DEFAULT_APPEARANCE }, environment, createdAt: now, updatedAt: now,
} }, { upsert: true }); } }, { upsert: true });
const doc = (await profiles.findOne({ _id: playerId }))!; const doc = (await profiles.findOne({ _id: playerId }))!;
const stats = emptyStats(); const stats = await readStats(playerId);
for await (const result of results.find({ playerIds: playerId }).sort({ order: 1, _id: 1 })) addResult(stats, result, playerId);
return { playerId, displayName: doc.displayName, appearance: normalizeAppearance(doc.appearance), stats, return { playerId, displayName: doc.displayName, appearance: normalizeAppearance(doc.appearance), stats,
unlockedColors: availableColorIds(stats), unlockedEquipment: availableEquipmentIds(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.saved = true;
match.engine.saved(emptyPlayerStats()); match.engine.saved(emptyPlayerStats());
} catch (error) { match.engine.saveFailed(); throw error; } } 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) { function schedule(match: RankedMatch) {
if (match.timer !== undefined) clock.clearTimeout(match.timer); 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; : clock.now() + RANKED_SAVE_RETRY_MS;
match.timer = clock.setTimeout(() => { match.timer = clock.setTimeout(() => {
match.timer = undefined; match.timer = undefined;
// advance schedules persistence retries itself; don't leak an unhandled timer rejection. // advance schedules persistence retries itself; never send raw DB errors to the logger.
void serial(() => advance(match)).catch(() => {}); void serial(() => advance(match)).catch(() => {
try { onError?.('RANKED_BACKGROUND_FAILED'); } catch { /* Logging must not break retries. */ }
});
}, Math.max(0, at - clock.now())); }, Math.max(0, at - clock.now()));
} }
async function advance(match: RankedMatch) { 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); match.actions = [null, null]; match.engine.beginTurn(state.reveal!.revealEndsAt);
} }
state = match.engine.view(); 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') { if (match.engine.view().phase === 'finished') {
match.finishedExpiresAt ??= [clock.now() + RANKED_FINISHED_TTL_MS, clock.now() + RANKED_FINISHED_TTL_MS]; match.finishedExpiresAt ??= [clock.now() + RANKED_FINISHED_TTL_MS, clock.now() + RANKED_FINISHED_TTL_MS];
await persist(match); await persist(match);
@@ -215,16 +268,27 @@ export async function createRankedService(db: Db, clock: Clock = systemClock, en
}, },
async leaderboard(playerId: string): Promise<Leaderboard> { async leaderboard(playerId: string): Promise<Leaderboard> {
if (closed) throw new ApiError(503, 'RANKED_CLOSED'); if (closed) throw new ApiError(503, 'RANKED_CLOSED');
await readProfile(playerId); const profile = await readProfile(playerId);
const totals = new Map<string, ModeStats>(); const cached = leaderboardCache;
for await (const result of results.find().sort({ order: 1, _id: 1 })) for (const id of result.playerIds) { if (cached && cached.expiresAt > clock.now() && (cached.complete || cached.selves.has(playerId)
const stats = totals.get(id) ?? emptyStats(); addResult(stats, result, id); totals.set(id, stats); || 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, // One scan serves concurrent viewers without taking the gameplay operation lock.
displayName: profile.displayName, wins: totals.get(profile._id)?.wins ?? 0, best: totals.get(profile._id)?.best ?? 0, const pending = leaderboardRead ??= scanLeaderboard();
})).sort((a, b) => b.best - a.best || b.wins - a.wins || (a.playerId < b.playerId ? -1 : a.playerId > b.playerId ? 1 : 0)) try {
.map((entry, index) => ({ rank: index + 1, ...entry })); const entries = await pending, self = entries.find(entry => entry.playerId === playerId);
return { entries: entries.slice(0, RANKED_LEADERBOARD_LIMIT), 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<void> { assertAvailable(playerId: string): Promise<void> {
return serial(async () => { expireQueue(); if (queue.has(playerId) || byPlayer.has(playerId)) throw new ApiError(409, 'MATCH_PENDING'); }); 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; queue.delete(candidate.playerId); continue;
} }
const first = await readProfile(candidate.playerId), second = await readProfile(playerId); 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], const match: RankedMatch = { id, engine, playerIds: [candidate.playerId, playerId],
appearances: [first.appearance, second.appearance], actions: [null, null], departed: new Set(), saved: false }; 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); } for (const id of match.playerIds) { queue.delete(id); byPlayer.set(id, match); }
schedule(match); break; 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. // Unfinished matches are deliberately not recovered or awarded after shutdown.
if (match.engine.view().phase !== 'finished') match.engine.abort('connection_failed'); if (match.engine.view().phase !== 'finished') match.engine.abort('connection_failed');
} }
byPlayer.clear(); matches.clear(); byPlayer.clear(); matches.clear(); statsCache.clear(); leaderboardCache = undefined;
}); });
}, },
}; };
+6 -2
View File
@@ -8,13 +8,17 @@ import type { PeerRoom } from '../shared/p2p-types.js';
import { normalizeAppearance } from '../shared/cosmetics.js'; import { normalizeAppearance } from '../shared/cosmetics.js';
import type { FighterAppearance } from '../shared/cosmetics.js'; import type { FighterAppearance } from '../shared/cosmetics.js';
import type { Pair } from '../shared/types.js'; import type { Pair } from '../shared/types.js';
import { createGlobalSettingsStore } from './global-settings.js';
import type { GlobalSettings } from '../shared/global-settings.js';
interface RoomDocument { interface RoomDocument {
settings?: GlobalSettings;
_id: string; code: string; hostGameSessionId: string; guestGameSessionId?: string; _id: string; code: string; hostGameSessionId: string; guestGameSessionId?: string;
members: string[]; open: boolean; status: 'waiting' | 'paired' | 'closed'; currentMatchId: string; members: string[]; open: boolean; status: 'waiting' | 'paired' | 'closed'; currentMatchId: string;
round: number; rematchReady: string[]; createdAt: Date; expiresAt: Date; round: number; rematchReady: string[]; createdAt: Date; expiresAt: Date;
appearances?: Pair<FighterAppearance>; appearances?: Pair<FighterAppearance>;
} }
export async function createRoomStore(db: Db, now: () => number = Date.now) { export async function createRoomStore(db: Db, now: () => number = Date.now) {
const settings = createGlobalSettingsStore(db);
const rooms = db.collection<RoomDocument>('rooms'); const rooms = db.collection<RoomDocument>('rooms');
await rooms.createIndex({ code: 1 }, { unique: true }); await rooms.createIndex({ code: 1 }, { unique: true });
await rooms.createIndex({ expiresAt: 1 }, { expireAfterSeconds: 0 }); 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); const guestAppearance = await appearance(sessionId);
try { try {
const doc = await rooms.findOneAndUpdate({ _id: before._id, open: true, status: 'waiting', guestGameSessionId: { $exists: false }, expiresAt: { $gt: new Date(now()) } }, { 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' }); }, { returnDocument: 'after' });
if (doc) return view(doc, sessionId); if (doc) return view(doc, sessionId);
} catch (error) { } catch (error) {
@@ -91,7 +95,7 @@ export async function createRoomStore(db: Db, now: () => number = Date.now) {
if (await db.collection<any>('game_sessions').findOne({ _id: { $in: before.members }, activeMatch: { $exists: true } })) throw new ApiError(409, 'MATCH_PENDING'); if (await db.collection<any>('game_sessions').findOne({ _id: { $in: before.members }, activeMatch: { $exists: true } })) throw new ApiError(409, 'MATCH_PENDING');
await rooms.updateOne({ _id: id, round, status: 'paired' }, { $addToSet: { rematchReady: sessionId } }); await rooms.updateOne({ _id: id, round, status: 'paired' }, { $addToSet: { rematchReady: sessionId } });
await rooms.updateOne({ _id: id, round, status: 'paired', rematchReady: { $all: before.members }, expiresAt: { $gt: new Date(now()) } }, { 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); return view(await read(id, sessionId), sessionId);
}, },
+11
View File
@@ -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);
}
+8 -6
View File
@@ -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 { chooseBotAction } from './bot.js';
import { isLegalAction, resolveTurn } from './rules.js'; import { isLegalAction, resolveTurn } from './rules.js';
import { emptyPlayerStats, type LocalEndReason, type LocalMatchState, type MatchCheckpoint, type MatchConfig } from './p2p-types.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', 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, version: 0, energy: [0, 0], turnId: 0, clockNow: config.clock.now(), startsAt: Infinity, deadline: 0,
ownAction: null, rematch: [false, false], opponentPresent: true, opponentLeft: false, 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) { function finish(winnerIndex: 0 | 1 | null, reason: LocalEndReason, aborted = false) {
if (state.phase === 'finished') return; if (state.phase === 'finished') return;
@@ -21,14 +23,14 @@ export function createMatchEngine(config: MatchConfig) {
return { return {
start(startsAt = config.clock.now()) { start(startsAt = config.clock.now()) {
if (state.version !== 0) throw new Error('ALREADY_STARTED'); 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 { beginTurn(startsAt = config.clock.now()): boolean {
if (state.phase === 'selecting' || state.phase === 'finished') return false; if (state.phase === 'selecting' || state.phase === 'finished') return false;
const earliest = state.phase === 'countdown' ? state.startsAt : state.reveal!.revealEndsAt; const earliest = state.phase === 'countdown' ? state.startsAt : state.reveal!.revealEndsAt;
if (startsAt < earliest || config.clock.now() < earliest) return false; if (startsAt < earliest || config.clock.now() < earliest) return false;
state.phase = 'selecting'; state.turnId++; state.version++; 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; state.ownAction = null; state.reveal = undefined;
bot = config.mode === 'bot' ? chooseBotAction(state.energy[1], state.energy[0], config.random) : null; bot = config.mode === 'bot' ? chooseBotAction(state.energy[1], state.energy[0], config.random) : null;
return true; return true;
@@ -40,7 +42,7 @@ export function createMatchEngine(config: MatchConfig) {
botAction: () => bot, botAction: () => bot,
resolve(actions: Pair<ActionId | null>): TurnResolution { resolve(actions: Pair<ActionId | null>): TurnResolution {
if (state.phase !== 'selecting') throw new Error('NOT_SELECTING'); 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.some((a, i) => a !== null && !isLegalAction(a, state.energy[i]))) throw new Error('INVALID_ACTION');
if (actions[0] === null || actions[1] === null) { if (actions[0] === null || actions[1] === null) {
const winner = actions[0] === null && actions[1] === null ? null : actions[0] === null ? 1 : 0; 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 = { ...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.turnId = state.phase === 'revealing' ? state.turnId + 1 : Math.max(1, state.turnId);
state.phase = 'selecting'; state.ownAction = null; state.reveal = undefined; 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); }, abort(reason: 'connection_failed' | 'protocol_error') { finish(null, reason, true); },
saved(stats: PlayerStats) { saved(stats: PlayerStats) {
+20
View File
@@ -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} 준비 중…`;
}
+4 -1
View File
@@ -1,9 +1,10 @@
import type { ActionId, EndReason, MatchFinished, MatchState, Mode, Pair, PlayerStats } from './types.js'; import type { ActionId, EndReason, MatchFinished, MatchState, Mode, Pair, PlayerStats } from './types.js';
import type { FighterAppearance } from './cosmetics.js'; import type { FighterAppearance } from './cosmetics.js';
import type { GlobalSettings } from './global-settings.js';
export type LocalOutcome = 'win' | 'loss' | 'draw' | 'aborted'; export type LocalOutcome = 'win' | 'loss' | 'draw' | 'aborted';
export type LocalEndReason = EndReason | 'connection_failed' | 'protocol_error'; export type LocalEndReason = EndReason | 'connection_failed' | 'protocol_error';
export interface ActiveMatch { matchId: string; seq: number; mode: Mode; roomId?: string; appearances?: Pair<FighterAppearance> } export interface ActiveMatch { matchId: string; seq: number; mode: Mode; roomId?: string; appearances?: Pair<FighterAppearance>; settings?: GlobalSettings }
export interface ResultSubmission extends ActiveMatch { outcome: LocalOutcome; reason: LocalEndReason } export interface ResultSubmission extends ActiveMatch { outcome: LocalOutcome; reason: LocalEndReason }
export interface RecordAck { status: 'applied' | 'duplicate' | 'stale'; stats: PlayerStats } export interface RecordAck { status: 'applied' | 'duplicate' | 'stale'; stats: PlayerStats }
export interface GameSessionAuth { gameSessionId: string; token: string } export interface GameSessionAuth { gameSessionId: string; token: string }
@@ -22,6 +23,7 @@ export interface SignalingPacket {
} }
export interface IceConfiguration { iceServers: RTCIceServer[]; expiresAt: number } export interface IceConfiguration { iceServers: RTCIceServer[]; expiresAt: number }
export interface LocalMatchState extends Omit<MatchState, 'serverNow' | 'finished'> { export interface LocalMatchState extends Omit<MatchState, 'serverNow' | 'finished'> {
settings?: GlobalSettings;
clockNow: number; aborted: boolean; clockNow: number; aborted: boolean;
appearances?: Pair<FighterAppearance>; appearances?: Pair<FighterAppearance>;
ranked?: boolean; ranked?: boolean;
@@ -29,6 +31,7 @@ export interface LocalMatchState extends Omit<MatchState, 'serverNow' | 'finishe
} }
export interface MatchCheckpoint { matchId: string; state: LocalMatchState; botAction: ActionId | null } export interface MatchCheckpoint { matchId: string; state: LocalMatchState; botAction: ActionId | null }
export interface MatchConfig { export interface MatchConfig {
settings?: GlobalSettings;
matchId: string; mode: Mode; selfIndex: 0 | 1; clock: { now(): number }; random?: () => number; matchId: string; mode: Mode; selfIndex: 0 | 1; clock: { now(): number }; random?: () => number;
} }
export interface PeerHeader { export interface PeerHeader {
+1 -1
View File
@@ -216,5 +216,5 @@ test('ranked results allow requeue after saving even when the opponent has left'
view.update({ ...state, version: 3, ranked: false }); view.update({ ...state, version: 3, ranked: false });
assert.equal(container.querySelector('.rematch').disabled, true); assert.equal(container.querySelector('.rematch').disabled, true);
assert.equal(container.querySelector('.rematch').textContent, '다시 대전'); assert.equal(container.querySelector('.rematch').textContent, '다시 대전');
assert.equal(container.querySelector('.rematch-status').textContent, '상대가 나갔습니다.'); assert.equal(container.querySelector('.rematch-status').textContent, '상대방이 나갔습니다');
}); });
+103 -11
View File
@@ -1,3 +1,4 @@
import { DEFAULT_GLOBAL_SETTINGS } from '../constants/global-settings.js';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import { createHash } from 'node:crypto'; 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'); 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 { class Element {
dataset: Record<string, string> = {}; dataset: Record<string, string> = {};
textContent = ''; innerHTML = ''; outerHTML = ''; hidden = false; textContent = ''; innerHTML = ''; outerHTML = ''; hidden = false;
children = new Map<string, Element>(); children = new Map<string, Element>();
style = { setProperty() {}, removeProperty() {} }; properties = new Map<string, string>();
classList = { toggle() {} }; 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() {} setAttribute() {} addEventListener() {} showModal() {} close() {} remove() {}
focus() { doc.activeElement = this; } focus() { doc.activeElement = this; }
append(node: Element) { appended.push(node); } append(node: Element) { appended.push(node); }
@@ -251,7 +254,7 @@ function domHarness(t: import('node:test').TestContext) {
const container = new Element(); const container = new Element();
const doc = { body: new Element(), activeElement: new Element(), createElement: () => new Element() }; const doc = { body: new Element(), activeElement: new Element(), createElement: () => new Element() };
const globals = { document: doc, HTMLElement: Element, window: { 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)) { for (const [key, value] of Object.entries(globals)) {
const previous = Object.getOwnPropertyDescriptor(globalThis, key); 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(); } } finally { view.destroy(); }
}); });
test('an open cutscene refreshes changed appearance art without restarting its stage or result control', t => { test('an open cutscene refreshes changed appearance art without restarting its stage', t => {
const { appended, state } = domHarness(t); const { appended, state } = domHarness(t, false);
state.matchId = 'equipment-cutscene'; state.phase = 'finished'; 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 }; state.finished = { matchId: state.matchId, winnerIndex: 0, reason: 'hit', stats: emptyPlayerStats(), revealEndsAt: state.reveal.revealEndsAt, saved: true };
const cutscene = createFinishCutscene(() => {}); const cutscene = createFinishCutscene(() => {});
try { try {
cutscene.update(state); cutscene.update(state);
const dialog = appended[0], button = dialog.querySelector('.cutscene-result'); const dialog = appended[0];
assert.equal(dialog.dataset.stage, 'complete'); assert.equal(dialog.dataset.stage, 'attack');
state.appearances![1] = { characterId: 1, colorId: 'white', hairStyle: 'bald', equipment: ['headband'] }; state.appearances![1] = { characterId: 1, colorId: 'white', hairStyle: 'bald', equipment: ['headband'] };
cutscene.update({ ...state, version: 2 }); cutscene.update({ ...state, version: 2 });
const page = dialog.querySelector('.cutscene-page'); const page = dialog.querySelector('.cutscene-page');
assert.ok(page.outerHTML.includes('class="bald-scalp"')); assert.ok(page.outerHTML.includes('class="bald-scalp"'));
assert.ok(page.outerHTML.includes('fill="#864237"')); assert.ok(page.outerHTML.includes('fill="#864237"'));
assert.equal(dialog.dataset.stage, 'complete'); assert.equal(dialog.dataset.stage, 'attack');
assert.equal(dialog.querySelector('.cutscene-result'), button);
page.outerHTML += '<!-- retained -->'; page.outerHTML += '<!-- retained -->';
cutscene.update({ ...state, version: 3 }); cutscene.update({ ...state, version: 3 });
assert.ok(page.outerHTML.endsWith('<!-- retained -->')); assert.ok(page.outerHTML.endsWith('<!-- retained -->'));
assert.equal(appended.length, 1); assert.equal(appended.length, 1);
} finally { cutscene.destroy(); } } 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(); }
});
+88
View File
@@ -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<string, string>(), 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(); }
});
+87
View File
@@ -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'));
});
+55
View File
@@ -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 assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import type { GameApi } from '../src/client/api.js'; import type { GameApi } from '../src/client/api.js';
import { createLobbyFeatures } from '../src/client/lobby-features.js'; import { createLobbyFeatures } from '../src/client/lobby-features.js';
import { normalizeAppearance, type FighterAppearance } from '../src/shared/cosmetics.js'; import { normalizeAppearance, type FighterAppearance } from '../src/shared/cosmetics.js';
import type { PlayerProfile } from '../src/shared/ranked-types.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. // A selector/event surface, not an HTML parser or rendering implementation.
// The test invokes the production selection/equip handlers and observes the // The test invokes the production selection/equip handlers and observes the
@@ -34,6 +38,57 @@ function documentSurface() {
return document; 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 => { test('a profile read started before equip cannot undo the saved appearance', async t => {
const document = documentSurface(), original = Object.getOwnPropertyDescriptor(globalThis, 'document'); const document = documentSurface(), original = Object.getOwnPropertyDescriptor(globalThis, 'document');
Object.defineProperty(globalThis, 'document', { configurable: true, value: document }); Object.defineProperty(globalThis, 'document', { configurable: true, value: document });
+16 -3
View File
@@ -6,12 +6,13 @@ import ts from 'typescript';
import { GAME_CHECKPOINT_KEY, GAME_ROOM_KEY } from '../constants/recording.js'; import { GAME_CHECKPOINT_KEY, GAME_ROOM_KEY } from '../constants/recording.js';
import { HOME_TELEPORT_APPEARANCE } from '../constants/home.js'; import { HOME_TELEPORT_APPEARANCE } from '../constants/home.js';
import { createGameApi, ApiClientError } from '../src/client/api.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'; import { emptyPlayerStats } from '../src/shared/p2p-types.js';
const source = readFileSync('src/client/main.ts', 'utf8').replace(/^import .*;\r?\n/gm, ''); 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 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)); }; 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<string, string>(), calls: string[] = [], polls = new Map<number, () => void>(), launched: string[] = []; const mem = new Map<string, string>(), calls: string[] = [], polls = new Map<number, () => void>(), launched: string[] = [];
const active = { matchId: 'm', seq: 1, mode: 'friend', roomId: 'r' }; const active = { matchId: 'm', seq: 1, mode: 'friend', roomId: 'r' };
const session: any = { gameSessionId: 's', token: 't'.repeat(43), stats: emptyPlayerStats(), lastAppliedSeq: kind === 'orphan' ? 0 : 1, ...(kind === 'orphan' ? { activeMatch: active } : {}) }; const 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<string, any>(); const nodes = new Map<string, any>();
const el = (id: string) => { if (!nodes.has(id)) nodes.set(id, { classList: { toggle() {}, add() {}, remove() {} }, addEventListener() {}, focus() {} }); return nodes.get(id); }; const 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: [], 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), GAME_NAME: '쎄쎄쎄:기공대전', HOME_TELEPORT_APPEARANCE, createGameApi: () => createGameApi(storage, send),
createGlobalSettingsCache: () => createGlobalSettingsCache(storage, send),
createAppFooter: () => ({ setConnection() {}, openRules() {} }),
createClientAnalytics: () => ({ ready() {}, screen() {}, gameFinished() {}, gameStarted() {}, error() {}, click() {} }), createClientAnalytics: () => ({ ready() {}, screen() {}, gameFinished() {}, gameStarted() {}, error() {}, click() {} }),
createResultReporter: (api: any) => ({ destroy() {}, resumePending: async () => {}, submit: api.record }), createResultReporter: (api: any) => ({ destroy() {}, resumePending: async () => {}, submit: api.record }),
createMatchView: () => ({ update() {}, destroy() {} }), createPeer: () => ({}), 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() {} }; }, 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); }, 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 }; return { ctx, calls, polls, room, mem, launched, open: () => open };
} }
test('already-expired room does not trap a restored result on exit', async () => { 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.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']); [...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);
});
+3 -2
View File
@@ -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(h.controller.view().phase, 'selecting');
assert.equal(await h.controller.submitAction('blast'), false); assert.equal(await h.controller.submitAction('blast'), false);
assert.equal(await h.controller.submitAction('charge'), true); 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(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().finished?.winnerIndex, 0);
assert.equal(h.controller.view().saveStatus, 'saved'); assert.equal(h.controller.view().saveStatus, 'saved');
assert.equal(h.recorded.length, 1); assert.equal(h.recorded[0].outcome, 'win'); assert.equal(h.recorded.length, 1); assert.equal(h.recorded[0].outcome, 'win');
+2 -2
View File
@@ -14,7 +14,7 @@ test('countdown and turn deadlines gate actions without a server', () => {
assert.equal(engine.lockAction('charge'), false); assert.equal(engine.lockAction('charge'), false);
at(3999); assert.equal(engine.beginTurn(), false); at(3999); assert.equal(engine.beginTurn(), false);
at(4000); assert.equal(engine.beginTurn(), true); 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('blast'), false);
assert.equal(engine.lockAction('charge'), true); assert.equal(engine.lockAction('charge'), true);
assert.equal(engine.lockAction('guard'), false); 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'); const confirmed = engine.checkpoint(); at(9700); engine.beginTurn(); engine.lockAction('blast');
at(11000); engine.resumeTurn(confirmed, 11000); at(11000); engine.resumeTurn(confirmed, 11000);
assert.deepEqual(engine.view().energy, [1, 1]); assert.equal(engine.view().turnId, 2); 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', () => { test('rollback and recovery keep published revisions increasing so the view accepts restored states', () => {
+22
View File
@@ -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);
});
+121 -9
View File
@@ -32,7 +32,7 @@ class Wire {
close() { this.connected = false; } close() { this.connected = false; }
disconnect() { this.connected = false; for (const h of this.states) h('disconnected'); } disconnect() { this.connected = false; for (const h of this.states) h('disconnected'); }
} }
function setup(onRematch?: () => Promise<void>, guestClockOffset = 0) { function setup(onRematch?: () => Promise<void>, guestClockOffset = 0, guestStartDelay = 0) {
const clock = new ManualClock(); clock.time = 1000; const clock = new ManualClock(); clock.time = 1000;
const a = new Wire(), b = new Wire(); a.remote = b; b.remote = a; const a = new Wire(), b = new Wire(); a.remote = b; b.remote = a;
const records: ResultSubmission[][] = [[], []]; const records: ResultSubmission[][] = [[], []];
@@ -40,7 +40,8 @@ function setup(onRematch?: () => Promise<void>, guestClockOffset = 0) {
const controllers = [a, b].map((peer, slot) => { const controllers = [a, b].map((peer, slot) => {
const memory = new Map<string, string>(); const memory = new Map<string, string>();
const storage = { getItem: (k: string) => memory.get(k) ?? null, setItem: (k: string, v: string) => { memory.set(k, v); }, removeItem: (k: string) => { memory.delete(k); } }; storages.push(storage); const 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, 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() }; } }, reporter: { submit: async data => { records[slot].push(data); return { status: 'applied', stats: emptyPlayerStats() }; } },
storage, storage,
@@ -51,9 +52,12 @@ function setup(onRematch?: () => Promise<void>, guestClockOffset = 0) {
test('two peers agree turns and record complementary results without server turn events', async () => { test('two peers agree turns and record complementary results without server turn events', async () => {
const h = setup(); const [a, b] = h.controllers; const h = setup(); const [a, b] = h.controllers;
try { 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'); 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]); 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); await h.clock.advance(1200); await a.submitAction('blast'); await b.submitAction('charge'); await h.clock.advance(4000);
assert.equal(h.records[0][0]?.outcome, 'win'); assert.equal(h.records[1][0]?.outcome, 'loss'); assert.equal(h.records[0][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(); } } 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 () => { test('loss of final presentation after digest acknowledgements preserves both agreed results', async () => {
const h = setup(); const [a, b] = h.controllers; const h = setup(); const [a, b] = h.controllers;
try { 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);
await a.submitAction('charge'); await b.submitAction('charge'); await h.clock.advance(5200); 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 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; }; 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); await h.clock.advance(14000);
@@ -73,6 +94,28 @@ test('loss of final presentation after digest acknowledgements preserves both ag
} finally { h.close(); } } 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 () => { test('crossed explicit leave requests produce one draw per player instead of two wins', async () => {
const h = setup(); const h = setup();
try { 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 () => { test('recovery starts a fresh epoch and ignores messages from the previous connection', async () => {
const h = setup(); const [a, b] = h.controllers; const h = setup(); const [a, b] = h.controllers;
try { 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); 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(h.a.epoch, 1); assert.equal(h.b.epoch, 1);
assert.equal(a.view().ownAction, null); assert.equal(b.view().ownAction, null); 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 () => { 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; const h = setup(undefined, 5000); const [a, b] = h.controllers;
try { 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); 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); await a.submitAction('charge'); await h.clock.advance(4000);
assert.equal(h.records[0][0]?.outcome, 'win'); assert.equal(h.records[1][0]?.outcome, 'loss'); assert.equal(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 () => { test('unrecoverable transport interruption aborts rather than inventing two wins', async () => {
const h = setup(); const h = setup();
try { 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(); 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 new Promise(r => setImmediate(r)); // drain already-delivered messages before advancing the recovery clock
await h.clock.advance(10000); 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 { restored.destroy(); }
} finally { h.close(); } } 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<void>(() => {});
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(); }
});
+14 -1
View File
@@ -1,12 +1,20 @@
// Diagnostic harness for real-browser testing; excluded from production bundles. // Diagnostic harness for real-browser testing; excluded from production bundles.
(() => { (() => {
const NativePeer = window.RTCPeerConnection, NativeSocket = window.WebSocket; 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', () => { document.addEventListener('DOMContentLoaded', () => {
const controls = document.createElement('aside'); const controls = document.createElement('aside');
controls.style.cssText = 'position:fixed;right:0;top:0;z-index:100;background:white;color:black;padding:4px'; controls.style.cssText = 'position:fixed;right:0;top:0;z-index:100;background:white;color:black;padding:4px';
controls.innerHTML = '<label>검증 입력 <select id="test-input"><option value="none">수동</option><option value="charge">충전 반복</option><option value="blast">충전 후 파</option><option value="guard">막기 반복</option></select></label>'; controls.innerHTML = '<label>검증 입력 <select id="test-input"><option value="none">수동</option><option value="charge">충전 반복</option><option value="blast">충전 후 파</option><option value="guard">막기 반복</option></select></label>';
document.body.append(controls); 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(() => { setInterval(() => {
const mode = controls.querySelector('select').value; const mode = controls.querySelector('select').value;
if (mode === 'none') return; 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; } } 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; } } 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) { 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 })); 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('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 => { channel.addEventListener('open', () => { void this.getStats().then(stats => {
+231 -11
View File
@@ -1,10 +1,12 @@
import { DEFAULT_GLOBAL_SETTINGS } from '../constants/global-settings.js';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import { randomBytes, randomUUID } from 'node:crypto'; import { randomBytes, randomUUID } from 'node:crypto';
import Fastify from 'fastify'; 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 { 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 { createRankedService } from '../src/server/ranked-service.js';
import { registerRankedApi } from '../src/server/ranked-api.js'; import { registerRankedApi } from '../src/server/ranked-api.js';
import { createGameSessionStore } from '../src/server/game-session-store.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'; import { createRoomStore } from '../src/server/rooms.js';
const integration = { skip: !process.env.TEST_MONGODB_URI }; const integration = { skip: !process.env.TEST_MONGODB_URI };
function observeResultReads(db: Db, afterRead: (filter?: Record<string, unknown>) => Promise<void>) {
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<string, unknown>) => {
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() { async function fixture() {
const client = await new MongoClient(process.env.TEST_MONGODB_URI!, { serverSelectionTimeoutMS: 5000 }).connect(); const client = await new MongoClient(process.env.TEST_MONGODB_URI!, { serverSelectionTimeoutMS: 5000 }).connect();
const db = client.db(`dragon_ball_test_ranked_${randomUUID().replaceAll('-', '')}`); 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); await s.join('player-a', a.gameSessionId);
const id = (await s.join('player-b', b.gameSessionId)).match!.matchId; 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 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/); 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 version = (await s.status('player-a', a.gameSessionId)).match!.version;
const locked = await s.action('player-a', a.gameSessionId, id, 1, 'charge'); 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!; const hidden = (await s.status('player-b', b.gameSessionId)).match!;
assert.equal(hidden.ownAction, null); assert.equal(hidden.reveal, undefined); assert.equal(hidden.ownAction, null); assert.equal(hidden.reveal, undefined);
assert.equal(hidden.version, version); assert.equal(hidden.version, version);
await s.action('player-b', b.gameSessionId, id, 1, 'charge'); const resolved = await s.action('player-b', b.gameSessionId, id, 1, 'charge');
await clock.advance(TURN_DURATION_MS); assert.equal(resolved.match?.phase, 'revealing');
assert.deepEqual((await s.status('player-b', b.gameSessionId)).match?.reveal?.actions, ['charge', 'charge']); assert.deepEqual((await s.status('player-b', b.gameSessionId)).match?.reveal?.actions, ['charge', 'charge']);
await clock.advance(REVEAL_DURATION_MS); await clock.advance(REVEAL_DURATION_MS);
await s.action('player-a', a.gameSessionId, id, 2, 'blast'); await s.action('player-a', a.gameSessionId, id, 2, 'blast');
await s.action('player-b', b.gameSessionId, id, 2, 'charge'); const finished = await s.action('player-b', b.gameSessionId, id, 2, 'charge');
await clock.advance(TURN_DURATION_MS); assert.equal(finished.match?.phase, 'finished');
await Promise.all(Array.from({ length: 8 }, () => s.status('player-a', a.gameSessionId))); await Promise.all(Array.from({ length: 8 }, () => s.status('player-a', a.gameSessionId)));
const ended = await s.status('player-b', b.gameSessionId); const ended = await s.status('player-b', b.gameSessionId);
assert.equal(ended.status, 'matched'); assert.equal(ended.match?.phase, 'finished'); 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 { try {
await f.service.join('player-a', f.a.gameSessionId); await f.service.join('player-a', f.a.gameSessionId);
const id = (await f.service.join('player-b', f.b.gameSessionId)).match!.matchId; 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'); 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); await f.clock.advance(RANKED_FINISHED_TTL_MS - 1);
assert.equal((await f.service.status('player-a', f.a2.gameSessionId)).match?.matchId, id); 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 { try {
await s.join('player-a', f.a.gameSessionId); await s.join('player-a', f.a.gameSessionId);
const id = (await s.join('player-b', f.b.gameSessionId)).match!.matchId; 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'); 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. // Jump to the deadline without executing timers: the request itself must settle it.
f.clock.time += TURN_DURATION_MS; f.clock.time += TURN_DURATION_MS;
await assert.rejects(s.action('player-b', f.b.gameSessionId, id, 1, 'guard'), /response lost after write/); 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.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/); 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 s.status('player-b', f.b.gameSessionId)).match?.finished?.reason, 'timeout');
assert.equal(await collection.countDocuments(), 1); 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++) { for (let n = 0; n < 5; n++) {
await s.join('player-a', a.gameSessionId); await s.join('player-a', a.gameSessionId);
const id = (await s.join('player-b', b.gameSessionId)).match!.matchId; 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 s.action('player-a', a.gameSessionId, id, 1, 'guard');
await clock.advance(TURN_DURATION_MS); await clock.advance(TURN_DURATION_MS);
assert.equal((await s.status('player-b', b.gameSessionId)).match?.finished?.reason, 'timeout'); 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 { try {
await s.join('player-a', f.a.gameSessionId); await s.join('player-a', f.a.gameSessionId);
const id = (await s.join('player-b', f.b.gameSessionId)).match!.matchId; 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 board = s.leaderboard('player-a'); await started;
const action = s.action('player-a', f.a.gameSessionId, id, 1, 'guard'); const action = s.action('player-a', f.a.gameSessionId, id, 1, 'guard');
let timer: ReturnType<typeof setTimeout>; let timer: ReturnType<typeof setTimeout>;
@@ -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'); assert.equal(progressed, true, 'leaderboard must not own the gameplay operation lock');
} finally { release(); await s.close(); await f.close(); } } 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<string, unknown>) => {
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<void>(resolve => { release = resolve; });
const started = new Promise<void>(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<any>('player_profiles').insertMany(players.map(_id => ({ _id, nameVersion: 1, displayName: _id })));
await f.db.collection<any>('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<void>(resolve => { release = resolve; });
const started = new Promise<void>(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<void>(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<typeof records.updateOne>) => {
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(); }
});
+18
View File
@@ -16,6 +16,7 @@ test('HTTP game registration survives server restart without game timers and old
try { try {
const url = await app.listen({ host: '127.0.0.1', port: 0 }); 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('/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 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 headers = { authorization: 'Bearer ' + session.token, 'x-game-session': session.gameSessionId };
const input = { requestId: 'register', matchId: 'bot-match', mode: 'bot', expectedPreviousSeq: 0 }; 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); 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(); } } } 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(); } }
});
+8
View File
@@ -68,6 +68,14 @@ const scenarios: [string, () => void][] = [
}], }],
['패배', () => result(selfIndex === 0 ? 1 : 0)], ['승리', () => result(selfIndex)], ['무승부', () => result(null)], ['패배', () => result(selfIndex === 0 ? 1 : 0)], ['승리', () => result(selfIndex)], ['무승부', () => result(null)],
['기 부족', () => { clearTimeout(timer); show({ ...state(), energy: [0, 0] }); }], ['기 부족', () => { 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()); }], ['오른쪽 플레이어', () => { selfIndex = selfIndex === 0 ? 1 : 0; clearTimeout(timer); show(state()); }],
]; ];
const picker = document.createElement('select'); picker.setAttribute('aria-label', '검증 상태'); const picker = document.createElement('select'); picker.setAttribute('aria-label', '검증 상태');