Compare commits

...
22 Commits
Author SHA1 Message Date
horoli b129d9c77b fix: join invited room after nickname registration 2026-10-26 18:24:48 +09:00
horoli 794cc4da04 feat: add persistent unique tags to player nicknames 2026-10-26 18:15:29 +09:00
horoli fc45534c7d feat: replace generated nicknames with filtered user input 2026-10-26 18:04:09 +09:00
horoli 5cef1c6f1e Add AdSense script to client page 2026-10-26 12:01:11 +09:00
horoli 6f44ec0d15 Clean up abandoned matches and fix analytics validation 2026-10-19 15:23:16 +09:00
horoli 65c3941e29 Add battle action shortcuts and clarify selection UI 2026-10-19 11:37:53 +09:00
horoli dd914d7181 fix: stabilize ranked lobby layout and pin online count 2026-10-16 12:02:23 +09:00
horoli c744e8c2a1 feat: require ranked match acceptance with configurable timeout 2026-10-15 18:59:52 +09:00
horoli 3540f392b0 Add social share preview and reduce mobile fighter size 2026-10-15 18:06:50 +09:00
horoli d87f5bc1db fix: aggregate presence samples every five minutes with seven-day retention 2026-10-15 17:59:43 +09:00
horoli 1a1f29e397 fix: clarify online player count label 2026-10-15 17:10:59 +09:00
horoli 293ed7bcc4 feat: allow three cumulative selection timeouts per match 2026-10-15 11:51:08 +09:00
horoli fc5e475f24 feat: select paired emote lines from own character 2026-10-15 11:38:28 +09:00
horoli 6a3a0ad4b9 Initialize delayed cutscenes before evaluating result audio 2026-10-15 00:16:48 +09:00
horoli a533d6e221 Recover presence connections after temporary database failures 2026-10-15 00:14:17 +09:00
horoli 32d00bb60a Order ranked emotes against delayed status responses 2026-10-15 00:14:17 +09:00
horoli 013807c873 Play result audio after finishing cutscene ends 2026-10-15 00:14:17 +09:00
horoli 32d32a6ebc Show authenticated unique online player count on home screen 2026-10-15 00:07:41 +09:00
horoli 7c414655c8 Anchor emote bubbles to their fighter avatar 2026-10-15 00:04:45 +09:00
horoli 16380eac79 Add original synthesized game audio and document sound provenance 2026-10-15 00:03:23 +09:00
horoli 05bc16e364 Keep emote menu clear of mobile action controls 2026-10-15 00:03:19 +09:00
horoli 493af700aa Add character emotes across bot friend and ranked matches 2026-10-14 23:56:32 +09:00
67 changed files with 1900 additions and 237 deletions
+7
View File
@@ -1,5 +1,12 @@
# 프로젝트 작업 규칙 # 프로젝트 작업 규칙
## 작업 완료 후 커밋
- 매 작업을 완료할 때 관련 검증을 수행한 뒤 해당 작업의 변경 사항을 반드시 커밋한다.
- 별도의 커밋 요청이나 확인을 기다리지 않는다. 사용자가 명시적으로 커밋하지 말라고 요청한 경우는 예외로 한다.
- 해당 작업과 무관한 기존 변경 사항은 커밋에 포함하지 않는다.
- 완료 보고에 커밋 해시와 검증 결과를 명시한다. 푸시는 사용자가 요청한 경우에 수행한다.
## UI 문구 최소화 ## UI 문구 최소화
화면 문구는 사용자의 행동과 판단에 필요한 정보만 표시한다. 화면 문구는 사용자의 행동과 판단에 필요한 정보만 표시한다.
+3 -3
View File
@@ -39,9 +39,9 @@ Fastify HTTP API·페이지·Socket.IO `/signal`은 한 앱 포트와 MongoDB
### 글로벌 설정 ### 글로벌 설정
MongoDB `global_settings`의 `_id: "global"` 문서에 `revision`, `turnDurationMs`(기본 3000), `finishCutsceneSpeed`(기본 1), `matchingDelayMs`(기본 1000), `matchSuccessDelayMs`(기본 1000), `matchPreparationDelayMs`(기본 3000)를 저장한다. 랭킹은 매칭 중 → 성공 → 준비 중, 친구전은 성공 → 준비 중 순서로 표시한다. 준비 화면은 `matchPreparationDelayMs`만큼 표시하며 안내 지연이 첫 턴 선택 시간을 줄이지 않는다. 컷씬 배속 2는 장면 전환과 애니메이션을 두 배 빠르게 재생한다. 컷씬 종료 후 결과 화면으로 자동 전환하며, 최소 결과 공개 시간(1.2초)을 지킨다. MongoDB `global_settings`의 `_id: "global"` 문서에 `revision`, `turnDurationMs`(기본 3000), `finishCutsceneSpeed`(기본 1), `matchingDelayMs`(기본 1000), `matchSuccessDelayMs`(기본 1000), `matchPreparationDelayMs`(기본 3000), `rankedAcceptTimeoutMs`(기본 10000)를 저장한다. 랭킹은 매칭 중 → 수락 대기 → 양쪽 수락 시 준비 중, 친구전은 성공 → 준비 중 순서로 표시한다. 랭킹 수락 대기는 매칭 시점의 설정값을 사용한다. 취소·시간 초과 시 승패 기록 없이 매칭을 해제하고, 수락한 상대만 자동 재매칭한다. 준비 화면은 `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 증가시킨다. 기존 관리자 Basic 인증으로 `GET /api/admin/global-settings`를 조회하고, 현재 `revision`과 설정값 전체을 `POST /api/admin/global-settings`에 보낸다. 예: `{"revision":1,"turnDurationMs":3000,"finishCutsceneSpeed":2,"matchingDelayMs":1000,"matchSuccessDelayMs":1000,"matchPreparationDelayMs":3000,"rankedAcceptTimeoutMs":10000}`. 저장 시 리비전이 자동 증가하며, 다른 관리자가 먼저 변경했다면 409를 반환한다. 선택 시간은 500~60000ms 정수, 컷씬 배속은 0.25~4 범위다. 매칭 중·성공·준비 지연은 각각 0~10000ms 정수다. 랭킹 수락 대기는 1000~60000ms 정수다. 기존 문서의 누락 필드는 서버 시작 시 기본값으로 추가된다. DB에서 직접 변경할 때도 값의 범위를 지키고 같은 원자적 업데이트에서 `revision`을 1 증가시킨다.
설정 변경은 서버 재시작 없이 다음 대전에 적용된다. 친구전은 페어링·재대전 시 같은 설정을 방에 저장하고, 봇·랭킹 대전도 생성 시 설정을 확정한다. 진행 중 대전과 새로고침 복구는 기존 대전의 설정을 유지한다. 브라우저는 최초 로딩·창 포커스/가시성 복귀·대전 시작 시 `GET /api/global-settings?revision=...`로 확인하고, 변경된 경우만 `localStorage` 캐시를 갱신한다. 조회 실패 시 유효한 캐시 또는 기본값을 유지하며 실제 대전에는 서버가 확정한 설정을 사용한다. 설정 변경은 서버 재시작 없이 다음 대전에 적용된다. 친구전은 페어링·재대전 시 같은 설정을 방에 저장하고, 봇·랭킹 대전도 생성 시 설정을 확정한다. 진행 중 대전과 새로고침 복구는 기존 대전의 설정을 유지한다. 브라우저는 최초 로딩·창 포커스/가시성 복귀·대전 시작 시 `GET /api/global-settings?revision=...`로 확인하고, 변경된 경우만 `localStorage` 캐시를 갱신한다. 조회 실패 시 유효한 캐시 또는 기본값을 유지하며 실제 대전에는 서버가 확정한 설정을 사용한다.
@@ -90,7 +90,7 @@ node --import tsx scripts/cleanup-legacy-data.ts --database ssessesse-ki-battle
node --import tsx scripts/cleanup-legacy-data.ts --database ssessesse-ki-battle --apply --backup .work/legacy-cleanup/removed-data.ejson node --import tsx scripts/cleanup-legacy-data.ts --database ssessesse-ki-battle --apply --backup .work/legacy-cleanup/removed-data.ejson
``` ```
이벤트·방문 세션·활동 표본은 90일 보관한다. 원문 UA·URL·방 코드·인증 토큰·IP·정밀 위치·오류 본문은 수집하지 않는다. 유입 도메인과 허용 캠페인 분류만 보관한다. 분석 전송 대기는 클라이언트 최대 100건·30분·6회 시도, 서버 최대 100건·30분이다. 분석 장애는 게임 진행과 결과 집계를 막지 않는다. 이벤트·방문 세션은 90일, 활동 표본과 수집 오류 이력은 7일 보관한다. 활동은 10초마다 측정하고 5분마다 최대 활성 방문자 수·측정 횟수·수집 오류 누적값을 저장한다. 정상 종료 시 남은 구간도 저장하며, 비정상 종료 시 아직 저장하지 않은 구간은 유실될 수 있다. 표본 조회 기간은 집계 종료 시각을 기준으로 한다. 서버 시작 시 기존 활동 표본의 만료일도 기록 시각 + 7일로 단축한다. 원문 UA·URL·방 코드·인증 토큰·IP·정밀 위치·오류 본문은 수집하지 않는다. 유입 도메인과 허용 캠페인 분류만 보관한다. 분석 전송 대기는 클라이언트 최대 100건·30분·6회 시도, 서버 최대 100건·30분이다. 분석 장애는 게임 진행과 결과 집계를 막지 않는다.
## 검증 ## 검증
+3 -1
View File
@@ -3,6 +3,8 @@ export const ACTIVITY_INTERVAL_MS = 60000;
export const ANALYTICS_BATCH_SIZE = 20; export const ANALYTICS_BATCH_SIZE = 20;
export const ACTIVE_WINDOW_MS = 90000; export const ACTIVE_WINDOW_MS = 90000;
export const PRESENCE_INTERVAL_MS = 10000; export const PRESENCE_INTERVAL_MS = 10000;
export const PRESENCE_SAVE_INTERVAL_MS = 5 * 60 * 1000;
export const PRESENCE_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
export const RETENTION_MS = 90 * 24 * 60 * 60 * 1000; export const RETENTION_MS = 90 * 24 * 60 * 60 * 1000;
export const RETRY_QUEUE_LIMIT = 100; export const RETRY_QUEUE_LIMIT = 100;
export const RETRY_MAX_AGE_MS = VISIT_SESSION_MS; export const RETRY_MAX_AGE_MS = VISIT_SESSION_MS;
@@ -10,7 +12,7 @@ export const RETRY_INTERVAL_MS = 5000;
export const MAX_EVENTS_PER_MINUTE = 120; export const MAX_EVENTS_PER_MINUTE = 120;
export const RETENTION_SWEEP_MS = 60 * 60 * 1000; export const RETENTION_SWEEP_MS = 60 * 60 * 1000;
export const CLIENT_EVENTS = ['page_view', 'screen_view', 'ui_action', 'client_error', 'game_started', 'game_finished'] as const; export const CLIENT_EVENTS = ['page_view', 'screen_view', 'ui_action', 'client_error', 'game_started', 'game_finished'] as const;
export const SCREENS = ['home', 'join', 'waiting', 'match', 'result'] as const; export const SCREENS = ['home', 'join', 'waiting', 'match', 'result', 'ranked-queue'] as const;
export const UI_ACTIONS = ['bot_start', 'room_create', 'room_join', 'rematch'] as const; export const UI_ACTIONS = ['bot_start', 'room_create', 'room_join', 'rematch'] as const;
export const CLIENT_ERROR_CODES = ['storage_unavailable', 'connection_failed', 'renderer_failed'] as const; export const CLIENT_ERROR_CODES = ['storage_unavailable', 'connection_failed', 'renderer_failed'] as const;
export const CAMPAIGN_VALUES = ['friend', 'organic', 'social', 'email', 'launch'] as const; export const CAMPAIGN_VALUES = ['friend', 'organic', 'social', 'email', 'launch'] as const;
+3
View File
@@ -1 +1,4 @@
export const GAME_NAME = '쎄쎄쎄:기공대전'; export const GAME_NAME = '쎄쎄쎄:기공대전';
export const GAME_DESCRIPTION = '기를 모으고, 공격하고, 막아라! 친구 또는 봇과 즐기는 심리전 대전 게임.';
export const GAME_URL = 'https://ssessesse.horoli.kr/';
export const GAME_SHARE_IMAGE = `${GAME_URL}assets/share-home.png`;
+11
View File
@@ -0,0 +1,11 @@
export const EMOTE_LABELS = { greet: '인사', thanks: '감사', mistake: '실수', threaten: '위협' } as const;
export type EmoteId = keyof typeof EMOTE_LABELS;
export const EMOTE_IDS = Object.keys(EMOTE_LABELS) as EmoteId[];
export const EMOTE_DURATION_MS = 3000;
export const EMOTE_COOLDOWN_MS = 4000;
export const EMOTE_LINES: Record<EmoteId, readonly [string, string]> = {
greet: ['반가워', '안녕하세요?'],
thanks: ['고마워', '감사합니다'],
mistake: ['실수였어', '실수할 수도 있지'],
threaten: ['끝장내주지', '도망칠 기회는 지금뿐이다'],
};
+2 -1
View File
@@ -11,6 +11,7 @@ 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 = DEFAULT_GLOBAL_SETTINGS.turnDurationMs; 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 = '2'; export const TIMEOUT_CHANCES = 3;
export const RULES_VERSION = '3';
export const BOT_VERSION = '1'; export const BOT_VERSION = '1';
export const APP_VERSION = '0.1.0'; export const APP_VERSION = '0.1.0';
+2 -2
View File
@@ -1,4 +1,4 @@
export const GLOBAL_SETTINGS_CACHE_KEY = 'dragon-ball:global-settings:v1'; export const GLOBAL_SETTINGS_CACHE_KEY = 'dragon-ball:global-settings:v1';
export const GLOBAL_SETTINGS_REQUEST_TIMEOUT_MS = 5000; 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 DEFAULT_GLOBAL_SETTINGS = { revision: 1, turnDurationMs: 3000, finishCutsceneSpeed: 1, matchingDelayMs: 1000, matchSuccessDelayMs: 1000, matchPreparationDelayMs: 3000, rankedAcceptTimeoutMs: 10000 };
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 } }; 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 }, rankedAcceptTimeoutMs: { min: 1000, max: 60000 } };
+7
View File
@@ -0,0 +1,7 @@
export const ACTION_SHORTCUTS = {
charge: 'Q',
blast: 'W',
guard: 'E',
beam: 'R',
dodge: 'T',
} as const;
+4
View File
@@ -0,0 +1,4 @@
export const PRESENCE_AUTH_REFRESH_MS = 60000;
export const PRESENCE_RETRY_MS = 5000;
export const MATCH_PRESENCE_GRACE_MS = 120000;
export const MATCH_CLEANUP_INTERVAL_MS = 30000;
+9
View File
@@ -0,0 +1,9 @@
export const PROFILE_NAME_MIN_LENGTH = 2;
export const PROFILE_NAME_MAX_LENGTH = 16;
export const PROFILE_NAME_TAG_DIGITS = 4;
export const PROFILE_NAME_BLOCKED_WORDS = [
'시발', '씨발', '씨팔', '시팔', '씨바', '씨벌', '씨부랄', '씹', '좆', '좃', '존나',
'병신', '븅신', '빙신', '개새끼', '새끼', '개새기', '개색기', '개색끼', '지랄', '염병',
'꺼져', '닥쳐', '느금마', '니애미', '니애비', '애미뒤진', '창녀', '걸레',
'ㅅㅂ', 'ㅆㅂ', 'ㅂㅅ', 'ㅈㄹ', 'ㅈㄴ', 'fuck', 'shit', 'bitch', 'asshole', 'cunt',
] as const;
-5
View File
@@ -7,8 +7,3 @@ export const RANKED_STATS_CACHE_LIMIT = 1000;
export const RANKED_STATS_CACHE_TTL_MS = 5 * 60 * 1000; export const RANKED_STATS_CACHE_TTL_MS = 5 * 60 * 1000;
export const RANKED_LEADERBOARD_CACHE_LIMIT = 1000; export const RANKED_LEADERBOARD_CACHE_LIMIT = 1000;
export const RANKED_LEADERBOARD_CACHE_TTL_MS = 5000; export const RANKED_LEADERBOARD_CACHE_TTL_MS = 5000;
export const PROFILE_NAME_VERSION = 1;
export const PROFILE_NAME_PREFIXES = ['푸른', '밝은', '고요한', '빛나는', '온화한', '맑은', '은빛', '씩씩한'] as const;
export const PROFILE_NAME_NOUNS = ['구름', '바람', '별빛', '노을', '하늘', '달빛', '물결', '나무'] as const;
export const PROFILE_NAME_SUFFIX_MIN = 1000;
export const PROFILE_NAME_SUFFIX_MAX = 10000;
+2 -1
View File
@@ -15,7 +15,8 @@ export const GAME_API_MESSAGES: Record<string, string> = {
ROOM_EXPIRED: '방이 만료되었습니다. 새 방을 만드세요.', ROOM_EXPIRED: '방이 만료되었습니다. 새 방을 만드세요.',
SERVER_ERROR: '처리하지 못했습니다. 다시 시도하세요.', SERVER_ERROR: '처리하지 못했습니다. 다시 시도하세요.',
INVALID_PROFILE: '프로필 정보를 확인하세요.', INVALID_PROFILE: '프로필 정보를 확인하세요.',
PROFILE_NAME_READONLY: '이름은 자동으로 지정되며 변경할 수 없습니다.', INVALID_PROFILE_NAME: '닉네임은 한글·영문·숫자·공백·_·-로 2~16자 입력하세요.',
PROFILE_NAME_PROFANITY: '사용할 수 없는 표현이 포함되어 있습니다. 다른 닉네임을 입력하세요.',
INVALID_APPEARANCE: '캐릭터, 머리, 도복색과 장비를 확인하세요.', INVALID_APPEARANCE: '캐릭터, 머리, 도복색과 장비를 확인하세요.',
COLOR_LOCKED: '아직 획득하지 않은 도복입니다.', COLOR_LOCKED: '아직 획득하지 않은 도복입니다.',
EQUIPMENT_LOCKED: '아직 획득하지 않은 장비입니다.', EQUIPMENT_LOCKED: '아직 획득하지 않은 장비입니다.',
+23
View File
@@ -0,0 +1,23 @@
export const SOUND_MUTED_KEY = 'ssessesse:sound-muted';
export const SOUND_MASTER_VOLUME = 0.35;
export const SOUND_EVENT_HISTORY = 128;
export const SOUND_MAX_VOICES = 32;
export interface SoundPart {
wave: OscillatorType | 'noise'; from: number; to: number; duration: number; volume: number; delay?: number;
}
// Original synthesis recipes: no recordings, samples, borrowed melodies or voices.
export const SOUNDS = {
click: [{ wave: 'sine', from: 900, to: 540, duration: .055, volume: .18 }],
charge: [{ wave: 'sine', from: 140, to: 480, duration: .48, volume: .2 }, { wave: 'triangle', from: 210, to: 720, duration: .38, volume: .1, delay: .06 }],
blast: [{ wave: 'noise', from: 2200, to: 400, duration: .22, volume: .3 }, { wave: 'sine', from: 320, to: 65, duration: .18, volume: .22 }],
guard: [{ wave: 'triangle', from: 840, to: 650, duration: .2, volume: .2 }, { wave: 'sine', from: 1260, to: 980, duration: .24, volume: .09 }],
beam: [{ wave: 'sawtooth', from: 90, to: 180, duration: .6, volume: .1 }, { wave: 'noise', from: 700, to: 3600, duration: .65, volume: .24 }],
dodge: [{ wave: 'noise', from: 3800, to: 250, duration: .18, volume: .15 }, { wave: 'sine', from: 1200, to: 300, duration: .12, volume: .1, delay: .08 }],
cutscene1: [{ wave: 'sawtooth', from: 65, to: 260, duration: .7, volume: .11 }, { wave: 'noise', from: 300, to: 2300, duration: .6, volume: .18 }],
cutscene2: [{ wave: 'noise', from: 2400, to: 120, duration: .4, volume: .45 }, { wave: 'sine', from: 150, to: 35, duration: .5, volume: .35 }],
cutscene3: [{ wave: 'noise', from: 650, to: 100, duration: .55, volume: .18 }, { wave: 'triangle', from: 100, to: 40, duration: .45, volume: .16 }],
win: [{ wave: 'sine', from: 440, to: 660, duration: .2, volume: .2 }, { wave: 'triangle', from: 660, to: 880, duration: .35, volume: .18, delay: .18 }],
loss: [{ wave: 'triangle', from: 330, to: 165, duration: .38, volume: .16 }, { wave: 'sine', from: 165, to: 82, duration: .45, volume: .16, delay: .24 }],
draw: [{ wave: 'sine', from: 380, to: 380, duration: .18, volume: .14 }, { wave: 'sine', from: 380, to: 380, duration: .2, volume: .1, delay: .25 }],
} satisfies Record<string, readonly SoundPart[]>;
export type SoundId = keyof typeof SOUNDS;
+34
View File
@@ -0,0 +1,34 @@
# 효과음 출처 및 권리 검토
검토일: 2026-10-14
## 적용 소스
모든 효과음은 `constants/sound.ts`의 수치 설정과 `src/client/sound.ts`의 Web Audio 합성 코드로 실행 시 생성한다. 외부 녹음물, 샘플 라이브러리, 음악 파일, 음성, TTS 서비스, 게임에서 추출한 오디오는 사용하지 않았다. 새로운 오디오 의존성도 추가하지 않았다.
| 대상 | 생성 방식 |
| --- | --- |
| 메뉴·행동 선택 | 짧은 사인파 하강음 |
| 기 충전 | 사인파·삼각파 상승음 |
| 파 | 저역 하강음과 필터링한 노이즈 |
| 막기 | 두 주파수의 삼각파·사인파 |
| 에네르기파 | 톱니파와 필터 주파수가 상승하는 노이즈 |
| 순간이동 | 빠르게 하강하는 노이즈·사인파 |
| 컷씬 1단계: 공격 | 상승하는 톱니파·노이즈 |
| 컷씬 2단계: 명중 | 저역 충격음·노이즈 |
| 컷씬 3단계: 후속 반응 | 감쇠하는 저역음·노이즈 |
| 승리·패배·무승부 | 각각 상승·하강·반복하는 짧은 합성 신호 |
기존 게임의 음악·멜로디·시그니처 효과음·캐릭터 음성을 복제하거나 재현하도록 설계하지 않았다. 하스스톤은 캐릭터마다 같은 감정표현 항목을 다른 대사로 표현하는 상호작용만 참고했다. 대사는 프로젝트에서 새로 작성했다.
## 검토 기준과 범위
미국 저작권청의 안내는 음악 저작물과 녹음물이 별개 권리임을 설명한다. 따라서 단순히 녹음을 새로 만들었다는 이유로 기존 곡을 사용할 수 있다고 판단하지 않았다. 이번 변경은 기존 곡 및 녹음물을 입력으로 사용하지 않는 자체 합성으로 구성했다.
- https://www.copyright.gov/engage/musicians/
- https://www.copyright.gov/register/pa-sr.html
- 감정표현 참고: https://hearthstone.blizzard.com/en-us/heroes/hunter
이번 구현에는 별도 제3자 음원 라이선스나 출처 표기 조건이 적용되는 외부 오디오 자산이 없다. 이 기록은 사용 소스에 대한 기술적 검토이며, 모든 관할에서의 비침해나 합성 결과의 독점적 저작권을 보증하는 법률 의견은 아니다. 기존 게임 이름·그래픽 등 사운드 변경 밖의 자산은 이 검토 범위에 포함하지 않는다.
향후 외부 음원으로 교체할 때는 원본 URL, 저작자, 라이선스 전문·버전·확인일, 상업 이용·수정·배포 허용 여부, 표시 의무를 파일별로 기록한다. '무료 다운로드'만으로 사용을 승인하지 않는다.
+3
View File
@@ -1,5 +1,6 @@
import { ACTIONS } from '../../constants/gameplay.js'; import { ACTIONS } from '../../constants/gameplay.js';
import type { TurnReveal } from '../shared/types.js'; import type { TurnReveal } from '../shared/types.js';
import { sound } from './sound.js';
export function createActionRenderer(container: HTMLElement) { export function createActionRenderer(container: HTMLElement) {
let last = '', timer: ReturnType<typeof setTimeout> | undefined; let last = '', timer: ReturnType<typeof setTimeout> | undefined;
@@ -22,6 +23,8 @@ export function createActionRenderer(container: HTMLElement) {
const id = `${reveal.matchId}:${reveal.turnId}`; const id = `${reveal.matchId}:${reveal.turnId}`;
if (id === last || serverNow >= reveal.revealEndsAt) return; if (id === last || serverNow >= reveal.revealEndsAt) return;
reset(); last = id; reset(); last = id;
// Finishing attacks are scored by the cutscene instead of stacking both presentations.
if (reveal.hitIndex === null) reveal.actions.forEach((action, slot) => sound.play(action, `${id}:action:${slot}`, slot === 0 ? -.45 : .45));
// Effects follow the authoritative reveal window, including reconnects. // Effects follow the authoritative reveal window, including reconnects.
container.style.setProperty('--reveal-duration', `${reveal.revealEndsAt - reveal.revealedAt}ms`); container.style.setProperty('--reveal-duration', `${reveal.revealEndsAt - reveal.revealedAt}ms`);
container.style.setProperty('--elapsed', `${-Math.min(reveal.revealEndsAt - reveal.revealedAt, Math.max(0, serverNow - reveal.revealedAt))}ms`); container.style.setProperty('--elapsed', `${-Math.min(reveal.revealEndsAt - reveal.revealedAt, Math.max(0, serverNow - reveal.revealedAt))}ms`);
+1 -1
View File
@@ -60,7 +60,7 @@ function render() {
const active = section('DAU · WAU · MAU', '조회 종료일을 포함한 최근 1/7/30일의 ID 중복 제거'); table(active, ['기간', '방문자', '플레이어'], [['DAU', number(o.dau.visitors), number(o.dau.players)], ['WAU', number(o.wau.visitors), number(o.wau.players)], ['MAU', number(o.mau.visitors), number(o.mau.players)]]); const active = section('DAU · WAU · MAU', '조회 종료일을 포함한 최근 1/7/30일의 ID 중복 제거'); table(active, ['기간', '방문자', '플레이어'], [['DAU', number(o.dau.visitors), number(o.dau.players)], ['WAU', number(o.wau.visitors), number(o.wau.players)], ['MAU', number(o.mau.visitors), number(o.mau.players)]]);
const days = section('일별 추이', 'KST 날짜 · 방문자/플레이어는 일별 중복 제거'); chart(days, report.daily, 'visitors', 'day', '#326dde'); table(days, ['날짜', '방문자', '플레이어', '생성 대전', '페이지 조회'], report.daily.map((d: any) => [d.day, d.visitors, d.players, d.matches, d.pageViews])); const days = section('일별 추이', 'KST 날짜 · 방문자/플레이어는 일별 중복 제거'); chart(days, report.daily, 'visitors', 'day', '#326dde'); table(days, ['날짜', '방문자', '플레이어', '생성 대전', '페이지 조회'], report.daily.map((d: any) => [d.day, d.visitors, d.players, d.matches, d.pageViews]));
const hours = section('시간별 추이', 'KST 시각 · 파란 막대: 순방문자'); chart(hours, report.hourly, 'visitors', 'hour', '#326dde'); table(hours, ['시간', '순방문자', '생성 대전'], report.hourly.map((d: any) => [d.hour.replace('T', ' '), d.visitors, d.matches])); const hours = section('시간별 추이', 'KST 시각 · 파란 막대: 순방문자'); chart(hours, report.hourly, 'visitors', 'hour', '#326dde'); table(hours, ['시간', '순방문자', '생성 대전'], report.hourly.map((d: any) => [d.hour.replace('T', ' '), d.visitors, d.matches]));
const peak = section('최대 동시 이용 표본', '10초 간격 표본 최대값 · 순간 최대값 미측정'); cards(peak, [['표본 수', report.sampledPeak.count], ['활성 방문자 추정', report.sampledPeak.active]]); const peak = section('최대 동시 이용 표본', '최근 7일 · 10초 측정 · 5분 집계 · 집계 종료 시각 기준 · 순간 최대값 미측정'); cards(peak, [['표본 수', report.sampledPeak.count], ['활성 방문자 추정', report.sampledPeak.active]]);
} else if (tab === 'acquisition') { } else if (tab === 'acquisition') {
for (const [key, label] of [['source', '유입 채널'], ['referrer', '외부 유입 도메인'], ['device', '기기'], ['browser', '브라우저']]) { for (const [key, label] of [['source', '유입 채널'], ['referrer', '외부 유입 도메인'], ['device', '기기'], ['browser', '브라우저']]) {
table(section(label), ['분류', '방문자', '페이지 조회'], report.acquisition[key].map((g: any) => [g.name, g.visitors, g.pageViews])); table(section(label), ['분류', '방문자', '페이지 조회'], report.acquisition[key].map((g: any) => [g.name, g.visitors, g.pageViews]));
+2
View File
@@ -1,10 +1,12 @@
/* Keep the footer in layout during combat so it never covers action buttons. */ /* Keep the footer in layout during combat so it never covers action buttons. */
body.player-app .app-footer #sound-toggle { margin-right: auto; white-space: nowrap; }
body.player-app .app > footer.app-footer { body.player-app .app > footer.app-footer {
display: flex; display: flex;
flex: 0 0 auto; flex: 0 0 auto;
width: 100vw; width: 100vw;
align-self: center; align-self: center;
flex-direction: row; flex-direction: row;
flex-wrap: wrap;
align-items: center; align-items: center;
justify-content: flex-end; justify-content: flex-end;
gap: 4px; gap: 4px;
+1 -1
View File
@@ -4,7 +4,7 @@ type FooterDialog = typeof FOOTER_LINKS[number]['id'];
export function createAppFooter(container: HTMLElement, dialogs: Record<FooterDialog, HTMLDialogElement>) { export function createAppFooter(container: HTMLElement, dialogs: Record<FooterDialog, HTMLDialogElement>) {
container.classList.add('app-footer'); 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 }) => container.innerHTML = `<button type="button" id="sound-toggle" class="sound-toggle quiet" aria-pressed="true">소리 끄기</button><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>`; `<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')!; const status = container.querySelector<HTMLElement>('#connection')!;
for (const { id } of FOOTER_LINKS) { for (const { id } of FOOTER_LINKS) {
+6
View File
@@ -4,6 +4,7 @@ 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'; import { DEFAULT_GLOBAL_SETTINGS } from '../../constants/global-settings.js';
import { sound } from './sound.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;
@@ -44,6 +45,10 @@ export function createFinishCutscene(onDismiss: () => void, appearances?: [Fight
: elapsed >= timing.fallen ? 'reaction' : elapsed >= timing.fallen ? 'reaction'
: elapsed >= timing.impact ? 'impact' : 'attack'; : elapsed >= timing.impact ? 'impact' : 'attack';
dialog.dataset.stage = stage; dialog.dataset.stage = stage;
if (stage !== 'complete') {
const cue = stage === 'attack' ? 'cutscene1' : stage === 'impact' ? 'cutscene2' : 'cutscene3';
sound.play(cue, `${activeMatch}:${cue}`);
}
if (complete) { if (complete) {
dismiss(); dismiss();
} else { } else {
@@ -57,6 +62,7 @@ export function createFinishCutscene(onDismiss: () => void, appearances?: [Fight
reducedMotion.addEventListener('change', advance); reducedMotion.addEventListener('change', advance);
return { return {
get active() { return !!dialog; },
update(state: LocalMatchState) { update(state: LocalMatchState) {
const reveal = state.reveal; const reveal = state.reveal;
if (activeMatch && activeMatch !== state.matchId) close(true); if (activeMatch && activeMatch !== state.matchId) close(true);
+68 -9
View File
@@ -1,4 +1,4 @@
import { RULES_VERSION } from '../../constants/gameplay.js'; import { RULES_VERSION, TIMEOUT_CHANCES } 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';
@@ -12,14 +12,16 @@ import { matchIntroductionDuration, matchPreparationDuration } from '../shared/m
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 {
matchId: string; slot: 0 | 1; engine: MatchEngine; peer: GamePeer; clock: GameClock; matchId: string; slot: 0 | 1; engine: MatchEngine; peer: GamePeer; clock: GameClock;
setOffset(offset: number): void; changed(): void; finished(): void; setOffset(offset: number): void; changed(): void; checkpointChanged(): void; finished(): void;
saved?: { confirmed?: MatchCheckpoint; epoch: number }; saved?: { confirmed?: MatchCheckpoint; epoch: number };
onEmote?(id: unknown, variant: unknown): void;
} }
export function createFriendMatch(config: FriendConfig) { export function createFriendMatch(config: FriendConfig) {
const { peer, engine, clock } = config; const { peer, engine, clock } = config;
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 timeoutReports: Pair<boolean | null> = [null, null];
let pendingTurnId: number | undefined, pendingCommit: Extract<PeerMessage, { type: 'turn:commit' }> | undefined; 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();
@@ -52,7 +54,7 @@ export function createFriendMatch(config: FriendConfig) {
function armProtocol() { later('protocol', () => { void recover(); }, 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], timeouts: state.timeouts ?? [0, 0],
finished: state.phase === 'finished', winnerIndex: state.finished?.winnerIndex ?? null, ...(state.finished ? { reason: state.finished.reason } : {}) }; finished: state.phase === 'finished', winnerIndex: state.finished?.winnerIndex ?? null, ...(state.finished ? { reason: state.finished.reason } : {}) };
} }
function sameCheckpoint(a: PublicCheckpoint, b: PublicCheckpoint) { function sameCheckpoint(a: PublicCheckpoint, b: PublicCheckpoint) {
@@ -98,28 +100,65 @@ export function createFriendMatch(config: FriendConfig) {
} }
async function resolved(actions: Pair<ActionId | null>) { async function resolved(actions: Pair<ActionId | null>) {
clear('deadline'); clear('deadline');
engine.resolve(actions); if (engine.view().phase !== 'finished') 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, state.timeouts, actions, state.finished?.winnerIndex ?? null, state.finished?.reason ?? null]);
if (stopped || recovering) return; if (stopped || recovering) return;
send('turn:resolved', { digest: ownDigest }); agree(); send('turn:resolved', { digest: ownDigest }); agree();
} }
function applyTimeout(deadline: number) {
const state = engine.view();
if (state.phase !== 'selecting' || deadline !== state.deadline || timeoutReports.includes(null)) return;
const missing = timeoutReports as Pair<boolean>;
if (!missing.some(Boolean)) { armProtocol(); return; }
clear('protocol');
const extended = engine.expireSelection(missing, deadline);
// Persist timeout evidence even before terminal digest agreement. Recovery
// must complete the third timeout, never restore another selection window.
confirmed.state.timeouts = [...engine.view().timeouts!];
config.checkpointChanged();
if (extended) {
config.changed(); armDeadline();
} else if (engine.view().phase === 'finished') {
run(() => resolved([null, null])); armProtocol();
}
}
function armDeadline() {
const deadline = engine.view().deadline;
timeoutReports = [null, null];
later('deadline', () => {
if (!peer.connected) { void recover(); return; }
armProtocol();
run(() => {
const missing = engine.view().ownAction === null;
timeoutReports[config.slot] = missing;
// Ordered delivery puts a last-moment commitment before this report.
send('turn:timeout', { deadline, missing, slot: config.slot }); applyTimeout(deadline);
});
}, deadline - now());
}
function resetAgreement() {
privateChoice = undefined; ownDigest = remoteDigest = remoteAck = ''; sentAck = false; presented = false; presentAt = undefined;
}
function selection() { function selection() {
const earlyCommit = pendingCommit; pendingTurnId = undefined; pendingCommit = undefined; const earlyCommit = pendingCommit; pendingTurnId = undefined; pendingCommit = undefined;
privateChoice = undefined; ownDigest = remoteDigest = remoteAck = ''; sentAck = false; presented = false; presentAt = undefined; resetAgreement();
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(); }, onPrivateState: value => { privateChoice = value; config.changed(); },
onPhase: phase => { if (phase !== 'commit' || now() >= state.deadline) armProtocol(); } }); onPhase: phase => { if (phase !== 'commit' || now() >= engine.view().deadline) armProtocol(); } });
if (earlyCommit) void exchange.receive(earlyCommit).catch(handleError); if (earlyCommit) void exchange.receive(earlyCommit).catch(handleError);
config.changed(); config.changed();
later('deadline', () => { armProtocol(); run(() => exchange!.commit(engine.view().ownAction)); }, state.deadline - now()); armDeadline();
} }
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);
if (engine.view().phase === 'finished') { resetAgreement(); run(() => resolved([null, null])); armProtocol(); }
else selection();
heartbeat(); return;
} }
pendingTurnId = engine.view().turnId + 1; pendingCommit = undefined; pendingTurnId = engine.view().turnId + 1; pendingCommit = undefined;
if (!begun) { begun = true; engine.start(startsAt - matchPreparationDuration(engine.view().settings)); confirmed = engine.checkpoint(); config.changed(); } if (!begun) { begun = true; engine.start(startsAt - matchPreparationDuration(engine.view().settings)); confirmed = engine.checkpoint(); config.changed(); }
@@ -160,10 +199,20 @@ export function createFriendMatch(config: FriendConfig) {
if (message.type === 'ping' || message.type === 'clock:ping') { send(message.type === 'ping' ? 'pong' : 'clock:pong', { sentAt: message.sentAt, remoteAt: now() }); return; } if (message.type === 'ping' || message.type === 'clock:ping') { send(message.type === 'ping' ? 'pong' : 'clock:pong', { sentAt: message.sentAt, remoteAt: now() }); return; }
if (message.type === 'clock:pong') { if (Number.isFinite(message.remoteAt)) clockReplies.get(message.sentAt)?.resolve(message.remoteAt); clockReplies.delete(message.sentAt); return; } if (message.type === 'clock:pong') { if (Number.isFinite(message.remoteAt)) clockReplies.get(message.sentAt)?.resolve(message.remoteAt); clockReplies.delete(message.sentAt); return; }
if (message.type === 'pong') return; if (message.type === 'pong') return;
if (message.type === 'emote') {
if (!recovering && engine.view().phase !== 'finished' && (message.variant === 0 || message.variant === 1)) config.onEmote?.(message.id, message.variant);
return;
}
if (message.type === 'ready') { if (message.slot === config.slot) throw new Error('INVALID_SLOT'); peerReady = true; maybeStart(); return; } if (message.type === 'ready') { if (message.slot === config.slot) throw new Error('INVALID_SLOT'); peerReady = true; maybeStart(); return; }
if (message.type === 'resume' || message.type === 'resume:ack') { if (message.type === 'resume' || message.type === 'resume:ack') {
if (!recovering) return; if (!recovering) return;
if (!sameCheckpoint(message.checkpoint, publicCheckpoint())) { abort('protocol_error'); return; } if (!sameCheckpoint(message.checkpoint, publicCheckpoint())) { abort('protocol_error'); return; }
const counts = message.checkpoint.timeouts ?? [0, 0];
if (!Array.isArray(counts) || counts.length !== 2 || counts.some(count => !Number.isInteger(count) || count < 0 || count > TIMEOUT_CHANCES)) throw new Error('INVALID_TIMEOUT');
// A disconnect can lose one timeout report. Keep the highest observed count
// while discarding unconfirmed choices, so reconnect never refunds a chance.
confirmed.state.timeouts = counts.map((count, slot) => Math.max(count, confirmed.state.timeouts?.[slot] ?? 0)) as Pair<number>;
config.checkpointChanged();
if (message.type === 'resume') { resumedPeer = true; send('resume:ack', { checkpoint: publicCheckpoint() }); } if (message.type === 'resume') { resumedPeer = true; send('resume:ack', { checkpoint: publicCheckpoint() }); }
else resumedAck = true; else resumedAck = true;
maybeResume(); return; maybeResume(); return;
@@ -187,6 +236,12 @@ export function createFriendMatch(config: FriendConfig) {
pendingCommit = message; return; pendingCommit = message; return;
} }
if (message.turnId !== engine.view().turnId) return; if (message.turnId !== engine.view().turnId) return;
if (message.type === 'turn:timeout') {
if (message.slot !== (config.slot === 0 ? 1 : 0) || typeof message.missing !== 'boolean') throw new Error('INVALID_TIMEOUT');
if (message.deadline !== engine.view().deadline) return;
if (timeoutReports[message.slot] !== null && timeoutReports[message.slot] !== message.missing) throw new Error('TIMEOUT_CHANGED');
timeoutReports[message.slot] = message.missing; applyTimeout(message.deadline); 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(); }
@@ -205,6 +260,10 @@ export function createFriendMatch(config: FriendConfig) {
catch { abort('connection_failed'); } catch { abort('connection_failed'); }
}, },
get recovering() { return recovering; }, get recovering() { return recovering; },
sendEmote(id: import('../../constants/emotes.js').EmoteId, variant: 0 | 1) {
if (stopped || recovering || !peer.connected || engine.view().phase === 'finished') return false;
try { send('emote', { id, variant }); return true; } catch { return false; }
},
submitAction() { if (!recovering && !stopped) run(() => exchange!.commit(engine.view().ownAction)); }, 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 }),
+5 -2
View File
@@ -1,7 +1,10 @@
<!doctype html> <!doctype html>
<html lang="ko"> <html lang="ko">
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"><meta name="theme-color" content="#ffffff"><title>쎄쎄쎄:기공대전</title></head> <head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"><meta name="theme-color" content="#ffffff"><title>쎄쎄쎄:기공대전</title>
<script async src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-3181563320128730" crossorigin="anonymous"></script>
</head>
<body class="player-app" data-screen="home"> <body class="player-app" data-screen="home">
<span id="online-count" class="online-count" role="status">접속 확인 중</span>
<div class="app"> <div class="app">
<main id="main"> <main id="main">
<section id="home" class="home"> <section id="home" class="home">
@@ -19,7 +22,7 @@
</main> </main>
<footer id="app-footer" class="app-footer"></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>선택은 바꿀 수 없습니다. 봇 대전은 선택 즉시, 사용자 간 대전은 양쪽 선택이 끝나면 함께 공개됩니다.</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>
+19
View File
@@ -1,4 +1,23 @@
.lobby-tools { display: flex; gap: 10px; margin-top: 16px; } .lobby-tools { display: flex; gap: 10px; margin-top: 16px; }
#nickname-dialog { max-width: 440px; }
#nickname-dialog form { display: grid; gap: 16px; }
#nickname-dialog input { width: 100%; min-width: 0; box-sizing: border-box; }
#nickname-notice:empty { display: none; }
.ranked-queue-actions { display: flex; justify-content: center; gap: 12px; }
.ranked-queue .accept-time { font-size: 28px; font-weight: 700; font-variant-numeric: tabular-nums; }
body.player-app[data-screen=ranked-queue] main { padding: 0; }
body.player-app .ranked-queue {
display: grid;
grid-template-rows: minmax(180px, 1fr) auto minmax(180px, 1fr);
flex: 1;
margin: 0 auto;
padding: 32px 0 16px;
}
.ranked-queue::before { content: ''; }
.ranked-queue .queue-fighter { width: min(260px, 26svh, 65vw); }
.ranked-queue .queue-details { display: grid; grid-template-rows: 36px 24px 36px 52px; gap: 8px; padding-top: 8px; }
.ranked-queue .queue-details > :is(h1, p) { margin: 0; align-self: center; }
.ranked-queue .queue-details > p[hidden] { display: block !important; visibility: hidden; }
.lobby-tools button { flex: 1; min-width: 0; min-height: 48px; padding: 10px; font-size: 17px; } .lobby-tools button { flex: 1; min-width: 0; min-height: 48px; padding: 10px; font-size: 17px; }
#home .home-actions > button:not(.primary) { min-height: 64px; font-size: 26px; } #home .home-actions > button:not(.primary) { min-height: 64px; font-size: 26px; }
#home .home-actions .split { grid-template-columns: repeat(2, minmax(0, 1fr)); } #home .home-actions .split { grid-template-columns: repeat(2, minmax(0, 1fr)); }
+107 -16
View File
@@ -1,5 +1,8 @@
import { COSMETIC_COLORS, COSMETIC_EQUIPMENT, EYEWEAR_IDS, HAIR_STYLES, SKIN_TONES, type EquipmentId, type HairStyle } from '../../constants/cosmetics.js'; import { COSMETIC_COLORS, COSMETIC_EQUIPMENT, EYEWEAR_IDS, HAIR_STYLES, SKIN_TONES, type EquipmentId, type HairStyle } from '../../constants/cosmetics.js';
import { RANKED_POLL_MS } from '../../constants/ranked.js'; import { RANKED_POLL_MS } from '../../constants/ranked.js';
import { PROFILE_NAME_MAX_LENGTH } from '../../constants/profile-name.js';
import { GAME_API_MESSAGES } from '../../constants/recording.js';
import { formatProfileName, profileNameError } from '../shared/profile-name.js';
import { appearanceKey, normalizeAppearance, type FighterAppearance } from '../shared/cosmetics.js'; import { appearanceKey, normalizeAppearance, type FighterAppearance } from '../shared/cosmetics.js';
import type { Leaderboard, PlayerProfile, RankedStatus } from '../shared/ranked-types.js'; import type { Leaderboard, PlayerProfile, RankedStatus } from '../shared/ranked-types.js';
import type { ActionId } from '../shared/types.js'; import type { ActionId } from '../shared/types.js';
@@ -20,16 +23,18 @@ interface LobbyOptions {
export function createLobbyFeatures(options: LobbyOptions) { export function createLobbyFeatures(options: LobbyOptions) {
const { api } = options; const { api } = options;
const actions = document.querySelector('.home-actions')!; const actions = document.querySelector('.home-actions')!;
const profileName = document.createElement('p'); profileName.id = 'profile-name'; profileName.hidden = true;
actions.after(profileName);
const rankedStart = document.createElement('button'); const rankedStart = document.createElement('button');
rankedStart.id = 'ranked-start'; rankedStart.className = 'primary'; rankedStart.disabled = true; rankedStart.id = 'ranked-start'; rankedStart.className = 'primary'; rankedStart.disabled = true;
rankedStart.innerHTML = '<span>랭킹 대전</span><span aria-hidden="true">→</span>'; rankedStart.innerHTML = '<span>랭킹 대전</span><span aria-hidden="true">→</span>';
actions.prepend(rankedStart); actions.prepend(rankedStart);
document.getElementById('bot-start')!.classList.remove('primary'); document.getElementById('bot-start')!.classList.remove('primary');
const tools = document.createElement('div'); tools.className = 'lobby-tools'; const tools = document.createElement('div'); tools.className = 'lobby-tools';
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><button id="nickname-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 role="status">랭킹대전 매칭 중…</h1><button id="ranked-cancel">매칭 취소</button>'; queue.innerHTML = '<div class="queue-fighter" aria-hidden="true"></div><div class="queue-details"><h1 role="status">랭킹대전 매칭 중…</h1><p class="accept-status" role="status" hidden></p><p class="accept-time" aria-label="수락 남은 시간" hidden></p><div class="ranked-queue-actions"><button id="ranked-accept" class="primary" hidden>수락</button><button id="ranked-cancel">매칭 취소</button></div></div>';
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>';
@@ -37,11 +42,50 @@ export function createLobbyFeatures(options: LobbyOptions) {
leaderboardDialog.innerHTML = '<div class="dialog-top"><h2>랭킹 리더보드</h2><button class="quiet" aria-label="리더보드 닫기">닫기</button></div><p class="ranking-self"></p><p class="ranking-order">최대 연승순 · 동률이면 누적 승순</p><div class="leaderboard-content" aria-live="polite"></div>'; leaderboardDialog.innerHTML = '<div class="dialog-top"><h2>랭킹 리더보드</h2><button class="quiet" aria-label="리더보드 닫기">닫기</button></div><p class="ranking-self"></p><p class="ranking-order">최대 연승순 · 동률이면 누적 승순</p><div class="leaderboard-content" aria-live="polite"></div>';
document.body.append(appearanceDialog, leaderboardDialog); document.body.append(appearanceDialog, leaderboardDialog);
const select = <T extends HTMLElement = HTMLElement>(root: ParentNode, selector: string) => root.querySelector<T>(selector)!; const select = <T extends HTMLElement = HTMLElement>(root: ParentNode, selector: string) => root.querySelector<T>(selector)!;
const nameDialog = document.createElement('dialog'); nameDialog.id = 'nickname-dialog';
nameDialog.setAttribute('aria-labelledby', 'nickname-title');
nameDialog.innerHTML = `<form><div class="dialog-top"><h2 id="nickname-title">닉네임 설정</h2><button type="button" class="quiet">닫기</button></div><label for="nickname-input">닉네임</label><input id="nickname-input" name="nickname" maxlength="${PROFILE_NAME_MAX_LENGTH}" autocomplete="off" aria-describedby="nickname-notice" required><p id="nickname-notice" role="status"></p><button type="submit" class="primary">저장</button></form>`;
document.body.append(nameDialog);
const nameInput = select<HTMLInputElement>(nameDialog, 'input');
const nameNotice = select(nameDialog, 'p');
const nameSave = select<HTMLButtonElement>(nameDialog, '[type=submit]');
const nameClose = select<HTMLButtonElement>(nameDialog, '[type=button]');
const nameOpen = document.getElementById('nickname-open') as HTMLButtonElement;
let nameSaving = false, finishName: (() => void) | undefined;
function openName() {
nameInput.value = profile?.displayName ?? ''; nameNotice.textContent = '';
nameClose.hidden = !profile?.displayName;
nameDialog.showModal(); nameInput.focus();
}
nameOpen.onclick = openName;
nameClose.onclick = () => { if (!nameSaving && profile?.displayName) nameDialog.close(); };
nameDialog.addEventListener('cancel', event => { if (nameSaving || !profile?.displayName) event.preventDefault(); });
select(nameDialog, 'form').addEventListener('submit', async event => {
event.preventDefault();
if (nameSaving) return;
const error = profileNameError(nameInput.value);
if (error) { nameNotice.textContent = GAME_API_MESSAGES[error]; return; }
nameSaving = true; nameSave.disabled = nameClose.disabled = true;
try {
const next = await api.request<PlayerProfile>('/api/profile', 'POST', { displayName: nameInput.value });
profileRevision++; updateProfile(next);
nameDialog.close(); finishName?.(); finishName = undefined;
} catch (error) { nameNotice.textContent = error instanceof Error ? error.message : '닉네임을 저장하지 못했습니다.'; }
finally { nameSaving = false; nameSave.disabled = nameClose.disabled = false; }
});
async function requireName() {
if (profile?.displayName) return;
await new Promise<void>(resolve => { finishName = resolve; openName(); });
}
const profileNotice = select(appearanceDialog, '.profile-notice'); const profileNotice = select(appearanceDialog, '.profile-notice');
const equip = select<HTMLButtonElement>(appearanceDialog, '.equip'); const equip = select<HTMLButtonElement>(appearanceDialog, '.equip');
const appearanceOpen = document.getElementById('appearance-open') as HTMLButtonElement; const appearanceOpen = document.getElementById('appearance-open') as HTMLButtonElement;
const leaderboardOpen = document.getElementById('leaderboard-open') as HTMLButtonElement; const leaderboardOpen = document.getElementById('leaderboard-open') as HTMLButtonElement;
const cancel = document.getElementById('ranked-cancel') as HTMLButtonElement; const cancel = document.getElementById('ranked-cancel') as HTMLButtonElement;
const accept = document.getElementById('ranked-accept') as HTMLButtonElement;
const acceptStatus = select(queue, '.accept-status'), acceptTime = select(queue, '.accept-time');
let confirmationTimer: ReturnType<typeof setTimeout> | undefined;
let confirmationDeadline = 0;
let profile: PlayerProfile | undefined, preview: FighterAppearance = normalizeAppearance(undefined); let profile: PlayerProfile | undefined, preview: FighterAppearance = normalizeAppearance(undefined);
let latest: RankedStatus | undefined, active = false, busy = false, profileBusy = false, timer: ReturnType<typeof setTimeout> | undefined, generation = 0; let latest: RankedStatus | undefined, active = false, busy = false, profileBusy = false, timer: ReturnType<typeof setTimeout> | undefined, generation = 0;
let profileRevision = 0; let profileRevision = 0;
@@ -51,6 +95,8 @@ export function createLobbyFeatures(options: LobbyOptions) {
function updateProfile(next: PlayerProfile) { function updateProfile(next: PlayerProfile) {
const appearanceChanged = !profile || appearanceKey(profile.appearance) !== appearanceKey(next.appearance); const appearanceChanged = !profile || appearanceKey(profile.appearance) !== appearanceKey(next.appearance);
profile = next; profile = next;
profileName.textContent = next.displayName ? formatProfileName(next) : '';
profileName.hidden = !next.displayName;
if (appearanceChanged) { if (appearanceChanged) {
options.onAppearance(next.appearance); options.onAppearance(next.appearance);
select(queue, '.queue-fighter').innerHTML = fighterArt(0, false, next.appearance); select(queue, '.queue-fighter').innerHTML = fighterArt(0, false, next.appearance);
@@ -148,7 +194,7 @@ export function createLobbyFeatures(options: LobbyOptions) {
} catch (error) { if (revision === profileRevision) profileNotice.textContent = error instanceof Error ? error.message : '프로필을 불러오지 못했습니다.'; } } catch (error) { if (revision === profileRevision) profileNotice.textContent = error instanceof Error ? error.message : '프로필을 불러오지 못했습니다.'; }
}; };
leaderboardOpen.onclick = async () => { leaderboardOpen.onclick = async () => {
select(leaderboardDialog, '.ranking-self').textContent = profile ? `내 이름 · ${profile.displayName}` : ''; select(leaderboardDialog, '.ranking-self').textContent = profile ? `내 이름 · ${formatProfileName(profile)}` : '';
const content = select(leaderboardDialog, '.leaderboard-content'); content.textContent = '불러오는 중'; leaderboardDialog.showModal(); const content = select(leaderboardDialog, '.leaderboard-content'); content.textContent = '불러오는 중'; leaderboardDialog.showModal();
try { try {
const board = await api.request<Leaderboard>('/api/ranked/leaderboard'); const board = await api.request<Leaderboard>('/api/ranked/leaderboard');
@@ -161,7 +207,7 @@ export function createLobbyFeatures(options: LobbyOptions) {
for (const entry of rows) { for (const entry of rows) {
const row = document.createElement('tr'); const row = document.createElement('tr');
if (entry.playerId === profile?.playerId) row.className = 'self-ranking'; if (entry.playerId === profile?.playerId) row.className = 'self-ranking';
for (const text of [entry.rank, entry.displayName + (entry.playerId === profile?.playerId ? ' (나)' : ''), entry.best, entry.wins]) { for (const text of [entry.rank, formatProfileName(entry) + (entry.playerId === profile?.playerId ? ' (나)' : ''), entry.best, entry.wins]) {
const cell = document.createElement('td'); cell.textContent = String(text); row.append(cell); const cell = document.createElement('td'); cell.textContent = String(text); row.append(cell);
} }
table.tBodies[0].append(row); table.tBodies[0].append(row);
@@ -177,11 +223,27 @@ export function createLobbyFeatures(options: LobbyOptions) {
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;
clearTimeout(confirmationTimer);
accept.hidden = acceptStatus.hidden = acceptTime.hidden = status.status !== 'confirming';
cancel.textContent = status.status === 'confirming' ? '취소' : '매칭 취소';
options.onAvailabilityChange(); options.onAvailabilityChange();
if (status.status === 'queued') showQueue('랭킹대전 매칭 중…'); if (status.status === 'queued') showQueue('랭킹대전 매칭 중…');
else if (status.status === 'confirming' && status.offer) {
confirmationDeadline = Date.now() + status.offer.expiresAt - status.offer.serverNow;
acceptStatus.textContent = status.offer.accepted ? '상대 수락 대기' : '';
showQueue('매칭 성공!'); updateConfirmation();
}
else if (status.match) options.onMatch(status.match); else if (status.match) options.onMatch(status.match);
else options.onHome(); else options.onHome();
} }
function updateConfirmation() {
clearTimeout(confirmationTimer);
if (!latest?.offer) return;
const remaining = Math.max(0, confirmationDeadline - Date.now());
acceptTime.textContent = `${Math.ceil(remaining / 1000)}초`;
accept.disabled = busy || latest.offer.accepted || remaining === 0;
if (remaining > 0) confirmationTimer = setTimeout(updateConfirmation, RANKED_POLL_MS);
}
function schedule() { function schedule() {
clearTimeout(timer); clearTimeout(timer);
if (!active || latest?.match?.phase === 'finished') return; if (!active || latest?.match?.phase === 'finished') return;
@@ -197,35 +259,55 @@ export function createLobbyFeatures(options: LobbyOptions) {
} }
async function start() { async function start() {
if (busy || active) return; if (busy || active) return;
busy = true; rankedStart.disabled = true; generation++; busy = true; rankedStart.disabled = true; cancel.disabled = true;
const currentGeneration = ++generation;
options.onAvailabilityChange(); options.onAvailabilityChange();
showQueue('랭킹대전 매칭 중…'); showQueue('랭킹대전 매칭 중…');
try { consume(await api.request<RankedStatus>('/api/ranked/queue', 'POST', {})); schedule(); } try {
catch (error) { options.onHome(); options.onError(error); } const status = await api.request<RankedStatus>('/api/ranked/queue', 'POST', {});
finally { busy = false; options.onAvailabilityChange(); } if (currentGeneration === generation) { consume(status); schedule(); }
}
catch (error) { if (currentGeneration === generation) { options.onHome(); options.onError(error); } }
finally { if (currentGeneration === generation) { busy = false; cancel.disabled = false; updateConfirmation(); options.onAvailabilityChange(); } }
} }
async function leave() { async function leave() {
if (busy) return; if (busy) return;
busy = true; cancel.disabled = true; generation++; clearTimeout(timer); busy = true; cancel.disabled = true; accept.disabled = true; clearTimeout(timer);
const currentGeneration = ++generation;
try { try {
const status = latest?.match const status = latest?.match
? await api.request<RankedStatus>('/api/ranked/leave', 'POST', { matchId: latest.match.matchId }) ? await api.request<RankedStatus>('/api/ranked/leave', 'POST', { matchId: latest.match.matchId })
: await api.request<RankedStatus>('/api/ranked/cancel', 'POST', {}); : await api.request<RankedStatus>('/api/ranked/cancel', 'POST', latest?.offer ? { matchId: latest.offer.matchId } : {});
consume(status); if (currentGeneration === generation) consume(status);
} catch (error) { options.onError(error); } } catch (error) { if (currentGeneration === generation) options.onError(error); }
finally { busy = false; cancel.disabled = false; options.onAvailabilityChange(); schedule(); } finally { if (currentGeneration === generation) { busy = false; cancel.disabled = false; updateConfirmation(); options.onAvailabilityChange(); schedule(); } }
} }
accept.onclick = async () => {
if (busy || !latest?.offer || latest.offer.accepted || Date.now() >= confirmationDeadline) return;
const matchId = latest.offer.matchId;
busy = true; accept.disabled = true; cancel.disabled = true; clearTimeout(timer);
const currentGeneration = ++generation;
options.onAvailabilityChange();
try {
const status = await api.request<RankedStatus>('/api/ranked/accept', 'POST', { matchId });
if (currentGeneration === generation) consume(status);
}
catch (error) { if (currentGeneration === generation) options.onError(error); }
finally { if (currentGeneration === generation) { busy = false; cancel.disabled = false; updateConfirmation(); options.onAvailabilityChange(); schedule(); } }
};
rankedStart.onclick = () => { void start(); }; rankedStart.onclick = () => { void start(); };
cancel.onclick = () => { void leave(); }; cancel.onclick = () => { void leave(); };
return { return {
get active() { return active; }, get active() { return active; },
get busy() { return busy; }, get busy() { return busy; },
setReady(ready: boolean) { rankedStart.disabled = !ready || !profile || busy || active; appearanceOpen.disabled = !ready || !profile || busy || active; leaderboardOpen.disabled = !ready || !profile || busy || active; }, setReady(ready: boolean) { rankedStart.disabled = !ready || !profile || busy || active; appearanceOpen.disabled = !ready || !profile || busy || active; leaderboardOpen.disabled = !ready || !profile || busy || active; nameOpen.disabled = !ready || !profile || busy || active; },
async initialize(restoreRanked: boolean) { async initialize(restoreRanked: boolean) {
generation++; clearTimeout(timer); generation++; clearTimeout(timer); clearTimeout(confirmationTimer);
if (!restoreRanked) { updateProfile(await api.request<PlayerProfile>('/api/profile')); return false; } busy = false; cancel.disabled = false;
if (!restoreRanked) { updateProfile(await api.request<PlayerProfile>('/api/profile')); await requireName(); return false; }
const status = await api.request<RankedStatus>('/api/ranked/status'); const status = await api.request<RankedStatus>('/api/ranked/status');
updateProfile(status.profile); latest = status; active = status.status !== 'idle'; updateProfile(status.profile); latest = status; active = status.status !== 'idle';
await requireName();
if (active) { consume(status); schedule(); } if (active) { consume(status); schedule(); }
return active; return active;
}, },
@@ -238,6 +320,15 @@ export function createLobbyFeatures(options: LobbyOptions) {
consume(status); return status.match?.ownAction === action; consume(status); return status.match?.ownAction === action;
} catch (error) { options.onError(error); return false; } } catch (error) { options.onError(error); return false; }
}, },
async sendEmote(id: import('../../constants/emotes.js').EmoteId) {
if (!latest?.match || !active) return false;
const matchId = latest.match.matchId;
try {
const status = await api.request<RankedStatus>('/api/ranked/emote', 'POST', { matchId, id });
if (!active || latest?.match?.matchId !== matchId) return false;
consume(status); return true;
} catch (error) { options.onError(error); return false; }
},
leave, leave,
async rematch() { await leave(); if (!active) await start(); }, async rematch() { await leave(); if (!active) await start(); },
}; };
+24 -7
View File
@@ -17,6 +17,8 @@ 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 { createGlobalSettingsCache } from './global-settings.js';
import { createAppFooter } from './app-footer.js'; import { createAppFooter } from './app-footer.js';
import { installSoundControls } from './sound.js';
import { createPresence } from './presence.js';
import './game.css'; import './game.css';
import './match.css'; import './match.css';
import './home.css'; import './home.css';
@@ -28,6 +30,8 @@ const el = <T extends HTMLElement = HTMLElement>(id: string) => document.getElem
const footer = createAppFooter(el('app-footer'), { const footer = createAppFooter(el('app-footer'), {
rules: el<HTMLDialogElement>('rules-dialog'), privacy: el<HTMLDialogElement>('privacy-dialog'), contact: el<HTMLDialogElement>('contact-dialog'), rules: el<HTMLDialogElement>('rules-dialog'), privacy: el<HTMLDialogElement>('privacy-dialog'), contact: el<HTMLDialogElement>('contact-dialog'),
}); });
installSoundControls();
const presence = createPresence(el('online-count'));
const globalSettings = createGlobalSettingsCache({ const globalSettings = createGlobalSettingsCache({
getItem: key => localStorage.getItem(key), setItem: (key, value) => localStorage.setItem(key, value), getItem: key => localStorage.getItem(key), setItem: (key, value) => localStorage.setItem(key, value),
}); });
@@ -110,7 +114,8 @@ function showMatch(state: LocalMatchState) {
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'), footer.openRules); () => { void perform(leave); }, () => setScreen('result'), undefined,
id => currentMatch?.ranked ? lobby!.sendEmote(id) : controller?.sendEmote(id) ?? false);
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;
@@ -176,6 +181,9 @@ async function acceptRoom(room: PeerRoom) {
waitingSocket.on('disconnect', () => { connection('연결 끊김', false, true); el('reconnect').hidden = false; }); waitingSocket.on('disconnect', () => { connection('연결 끊김', false, true); el('reconnect').hidden = false; });
waitingSocket.connect(); waitingSocket.connect();
} }
async function joinRoom(code: string) {
await acceptRoom(await api.request<PeerRoom>('/api/rooms/join', 'POST', { code: code.trim().toUpperCase(), requestId: crypto.randomUUID() }));
}
async function rematch() { async function rematch() {
if (!currentMatch?.finished?.saved) return; if (!currentMatch?.finished?.saved) return;
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; }
@@ -241,6 +249,7 @@ async function boot() {
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 Promise.all([api.ensureSession(token), globalSettings.refresh()]); const [session] = await Promise.all([api.ensureSession(token), globalSettings.refresh()]);
presence.connect(session);
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;
@@ -251,7 +260,7 @@ async function boot() {
const active = api.session.activeMatch; const active = api.session.activeMatch;
try { try {
if (await lobby?.initialize(!active && !currentRoom)) { ready = true; return; } if (await lobby?.initialize(!active && !currentRoom)) { ready = true; return; }
} catch (error) { errorNotice(error); } } catch (error) { errorNotice(error); return; }
if (active) { if (active) {
if (active.mode === 'friend' && saved?.activeMatch.matchId !== active.matchId) { if (active.mode === 'friend' && saved?.activeMatch.matchId !== active.matchId) {
await reporter.submit({ ...active, outcome: 'aborted', reason: 'connection_failed' }); await reporter.submit({ ...active, outcome: 'aborted', reason: 'connection_failed' });
@@ -286,7 +295,14 @@ async function boot() {
} else if (currentRoom) { } else if (currentRoom) {
try { await acceptRoom(await api.request<PeerRoom>('/api/rooms/' + currentRoom.id)); } try { await acceptRoom(await api.request<PeerRoom>('/api/rooms/' + currentRoom.id)); }
catch (error) { rememberRoom(); setScreen('home'); errorNotice(error); } catch (error) { rememberRoom(); setScreen('home'); errorNotice(error); }
} else setScreen(new URLSearchParams(location.search).has('room') ? 'join' : 'home'); } else {
setScreen(new URLSearchParams(location.search).has('room') ? 'join' : 'home');
if (pendingInvitation) {
const code = pendingInvitation; pendingInvitation = undefined;
analytics.click('room_join');
try { await joinRoom(code); } catch (error) { errorNotice(error); }
}
}
ready = true; ready = true;
if (!storageAvailable) { notice('저장소를 사용할 수 없어 새로고침 시 기록을 복원할 수 없습니다.'); analytics.error('storage_unavailable', currentScreen || 'home'); } if (!storageAvailable) { notice('저장소를 사용할 수 없어 새로고침 시 기록을 복원할 수 없습니다.'); analytics.error('storage_unavailable', currentScreen || 'home'); }
} catch (error) { errorNotice(error); } } catch (error) { errorNotice(error); }
@@ -296,7 +312,7 @@ el('bot-start').onclick = () => { void perform(() => launch('bot'), 'bot_start')
el('room-create').onclick = () => { void perform(async () => acceptRoom(await api.createRoom()), 'room_create'); }; el('room-create').onclick = () => { void perform(async () => acceptRoom(await api.createRoom()), 'room_create'); };
el('join-open').onclick = () => { setScreen('join'); el<HTMLInputElement>('room-code').focus(); }; el('join-open').onclick = () => { setScreen('join'); el<HTMLInputElement>('room-code').focus(); };
el('join-close').onclick = () => setScreen('home'); el('join-close').onclick = () => setScreen('home');
el('join-form').onsubmit = e => { e.preventDefault(); void perform(async () => acceptRoom(await api.request<PeerRoom>('/api/rooms/join', 'POST', { code: el<HTMLInputElement>('room-code').value.trim().toUpperCase(), requestId: crypto.randomUUID() })), 'room_join'); }; el('join-form').onsubmit = e => { e.preventDefault(); void perform(() => joinRoom(el<HTMLInputElement>('room-code').value), 'room_join'); };
el('waiting-leave').onclick = () => { void perform(leave); }; el('waiting-leave').onclick = () => { void perform(leave); };
el('reconnect').onclick = () => { el('reconnect').onclick = () => {
if (controller?.view().phase === 'finished') { void controller.retrySave(); return; } if (controller?.view().phase === 'finished') { void controller.retrySave(); return; }
@@ -312,11 +328,12 @@ 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('');
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(); let pendingInvitation = invitation && /^[A-Z0-9]{6}$/i.test(invitation) ? invitation.toUpperCase() : undefined;
if (pendingInvitation) el<HTMLInputElement>('room-code').value = pendingInvitation;
lobby = createLobbyFeatures({ lobby = createLobbyFeatures({
api, api,
onQueue: message => { setScreen('ranked-queue'); connection(message, true); }, onQueue: message => { setScreen('ranked-queue'); connection(message, true); notice(); el('reconnect').hidden = true; },
onMatch: state => showMatch(state), onMatch: state => { el('reconnect').hidden = true; 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 => {
el('waiting-fighter').innerHTML = fighterArt(0, false, appearance); el('waiting-fighter').innerHTML = fighterArt(0, false, appearance);
+22 -5
View File
@@ -4,6 +4,8 @@ import type { ActiveMatch, LocalMatchState, MatchCheckpoint, RecordAck, ResultSu
import type { ActionId } from '../shared/types.js'; import type { ActionId } from '../shared/types.js';
import type { SessionStorage } from './result-reporter.js'; import type { SessionStorage } from './result-reporter.js';
import { createFriendMatch, type GamePeer } from './friend-match.js'; import { createFriendMatch, type GamePeer } from './friend-match.js';
import { createEmotes } from '../shared/emotes.js';
import type { EmoteId } from '../../constants/emotes.js';
export interface GameClock { now(): number; setTimeout(fn: () => void, ms: number): any; clearTimeout(id: any): void } export interface GameClock { now(): number; setTimeout(fn: () => void, ms: number): any; clearTimeout(id: any): void }
export interface ControllerConfig { export interface ControllerConfig {
@@ -20,12 +22,18 @@ export function createMatchController(config: ControllerConfig) {
const engine = createMatchEngine({ ...config.activeMatch, selfIndex: config.selfIndex, clock: { now: () => clock.now() + offset }, random: config.random }); const engine = createMatchEngine({ ...config.activeMatch, selfIndex: config.selfIndex, clock: { now: () => clock.now() + offset }, random: config.random });
let friend: ReturnType<typeof createFriendMatch> | undefined; let friend: ReturnType<typeof createFriendMatch> | undefined;
const listeners = new Set<(state: LocalMatchState) => void>(); const listeners = new Set<(state: LocalMatchState) => void>();
const emotes = createEmotes();
const view = () => ({ ...engine.view(), appearances: config.activeMatch.appearances, emotes: emotes.view(clock.now() + offset) });
let timer: any, destroyed = false, started = false, recording = false; let timer: any, destroyed = false, started = false, recording = false;
function publish() { function saveCheckpoint() {
if (destroyed) return; if (destroyed) return;
const checkpoint: ControllerCheckpoint = { gameSessionId: config.gameSessionId, activeMatch: config.activeMatch, checkpoint: engine.checkpoint(), epoch: 0, ...friend?.snapshot() }; const checkpoint: ControllerCheckpoint = { gameSessionId: config.gameSessionId, activeMatch: config.activeMatch, checkpoint: engine.checkpoint(), epoch: 0, ...friend?.snapshot() };
config.storage.setItem(GAME_CHECKPOINT_KEY, JSON.stringify(checkpoint)); config.storage.setItem(GAME_CHECKPOINT_KEY, JSON.stringify(checkpoint));
for (const listener of listeners) listener({ ...engine.view(), appearances: config.activeMatch.appearances }); }
function publish() {
if (destroyed) return;
saveCheckpoint();
for (const listener of listeners) listener(view());
} }
async function record() { async function record() {
const state = engine.view(); if (!state.finished || state.finished.saved || recording || destroyed) return; const state = engine.view(); if (!state.finished || state.finished.saved || recording || destroyed) return;
@@ -63,7 +71,8 @@ export function createMatchController(config: ControllerConfig) {
if (config.activeMatch.mode === 'friend') { if (config.activeMatch.mode === 'friend') {
if (!config.peer) throw new Error('PEER_REQUIRED'); if (!config.peer) throw new Error('PEER_REQUIRED');
friend = createFriendMatch({ matchId: config.activeMatch.matchId, slot: config.selfIndex, engine, peer: config.peer, clock, friend = createFriendMatch({ matchId: config.activeMatch.matchId, slot: config.selfIndex, engine, peer: config.peer, clock,
setOffset: value => { offset = value; }, changed: publish, finished: () => { void record(); }, saved }); setOffset: value => { offset = value; }, changed: publish, checkpointChanged: saveCheckpoint, finished: () => { void record(); }, saved,
onEmote: (id, variant) => { if (emotes.send(config.selfIndex === 0 ? 1 : 0, id, clock.now() + offset, variant)) publish(); } });
await friend.start(); return; await friend.start(); return;
} }
if (!saved) engine.start(); if (!saved) engine.start();
@@ -86,8 +95,16 @@ export function createMatchController(config: ControllerConfig) {
await config.onRematch?.(); await config.onRematch?.();
if (!destroyed) friend?.requestRematch(); if (!destroyed) friend?.requestRematch();
}, },
view: () => ({ ...engine.view(), appearances: config.activeMatch.appearances }), sendEmote(id: EmoteId) {
subscribe(listener: (state: LocalMatchState) => void) { listeners.add(listener); listener({ ...engine.view(), appearances: config.activeMatch.appearances }); return () => { listeners.delete(listener); }; }, if (destroyed || !started || engine.view().phase === 'finished' || friend?.recovering) return false;
const now = clock.now() + offset;
if (config.activeMatch.mode === 'friend' && !config.peer?.connected) return false;
if (!emotes.send(config.selfIndex, id, now)) return false;
if (friend && !friend.sendEmote(id, emotes.view(now)[config.selfIndex]!.variant)) return false;
publish(); return true;
},
view,
subscribe(listener: (state: LocalMatchState) => void) { listeners.add(listener); listener(view()); return () => { listeners.delete(listener); }; },
retrySave: record, retrySave: record,
destroy() { destroyed = true; clock.clearTimeout(timer); friend?.destroy(); listeners.clear(); }, destroy() { destroyed = true; clock.clearTimeout(timer); friend?.destroy(); listeners.clear(); },
}; };
+79 -13
View File
@@ -1,4 +1,4 @@
import { ACTIONS, ACTION_IDS, MATCH_COUNTDOWN_MS } from '../../constants/gameplay.js'; import { ACTIONS, ACTION_IDS, MATCH_COUNTDOWN_MS, TIMEOUT_CHANCES } 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';
@@ -6,19 +6,57 @@ 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'; import { matchIntroductionMessage } from '../shared/match-introduction.js';
import { EMOTE_COOLDOWN_MS, EMOTE_IDS, EMOTE_LABELS, EMOTE_LINES, type EmoteId } from '../../constants/emotes.js';
import { sound } from './sound.js';
import { ACTION_SHORTCUTS } from '../../constants/match-controls.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="fighter-position"></div>
<div class="avatar"><div class="emote-bubble" role="status" hidden></div><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><button type="button" class="emote-trigger" data-emote-trigger="${i}" aria-label="내 캐릭터 감정표현 선택" aria-expanded="false" aria-controls="emote-choices" hidden></button></div>
<div class="fighter-caption"><span class="choice-status"></span><span class="action-label"></span><span class="energy-change" aria-hidden="true"></span></div> <div class="fighter-caption"><span class="choice-status"></span><span class="action-label"></span><span class="energy-change" aria-hidden="true"></span></div>
</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 = () => {}, appearances?: [FighterAppearance, FighterAppearance], onEmote?: (id: EmoteId) => boolean | Promise<boolean>) {
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> 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></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="timeout-chances" aria-label="남은 초읽기 기회"></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>
<div class="emote-menu" id="emote-choices" role="group" aria-label="감정표현 선택" hidden>${EMOTE_IDS.map(id => `<button type="button" data-emote="${id}">${EMOTE_LABELS[id]}</button>`).join('')}</div>
<div class="match-result" hidden><div class="result-stats"></div><p class="save-status" role="status"></p><div class="split"><button class="rematch primary">다시 대전</button><button class="home-return">시작 화면</button></div><p class="rematch-status" role="status"></p></div>`; <div class="match-result" hidden><div class="result-stats"></div><p class="save-status" role="status"></p><div class="split"><button class="rematch primary">다시 대전</button><button class="home-return">시작 화면</button></div><p class="rematch-status" role="status"></p></div>`;
const arena = container.querySelector<HTMLElement>('.arena')!; const arena = container.querySelector<HTMLElement>('.arena')!;
const emoteMenu = container.querySelector<HTMLElement>('.emote-menu')!;
let emoteReadyAt = 0, sendingEmote = false, emoteOpen = false;
const emoteTriggers = Array.from(container.querySelectorAll<HTMLButtonElement>('[data-emote-trigger]'));
function closeEmotes(restoreFocus = false) {
emoteOpen = false; emoteMenu.hidden = true;
emoteTriggers.forEach(button => button.setAttribute('aria-expanded', 'false'));
if (restoreFocus) emoteTriggers.find(button => Number(button.dataset.emoteTrigger) === current?.selfIndex)?.focus({ preventScroll: true });
}
emoteTriggers.forEach(button => {
button.onclick = () => {
if (!current || !onEmote || Number(button.dataset.emoteTrigger) !== current.selfIndex || current.phase === 'finished' || !current.opponentPresent) return;
if (emoteOpen) { closeEmotes(); return; }
const actor = button.getBoundingClientRect(), root = container.getBoundingClientRect();
emoteMenu.style.left = `${Math.max(8, Math.min(root.width - 164, actor.left - root.left + actor.width / 2 - 78))}px`;
emoteMenu.style.top = `${Math.max(72, actor.top - root.top - 104)}px`;
emoteOpen = true; tick();
button.setAttribute('aria-expanded', 'true');
emoteMenu.querySelector<HTMLButtonElement>('button:not(:disabled)')?.focus({ preventScroll: true });
};
});
container.onpointerdown = event => {
if (event.target instanceof Element && !event.target.closest('.emote-menu, .emote-trigger')) closeEmotes();
};
container.onkeydown = event => { if (event.key === 'Escape' && emoteOpen) { event.preventDefault(); closeEmotes(true); } };
emoteMenu.querySelectorAll<HTMLButtonElement>('[data-emote]').forEach(button => {
button.onclick = async () => {
if (!current || sendingEmote || Date.now() < emoteReadyAt) return;
const matchId = current.matchId;
sendingEmote = true; closeEmotes(true);
try { if (await onEmote?.(button.dataset.emote as EmoteId) && current?.matchId === matchId) emoteReadyAt = Date.now() + EMOTE_COOLDOWN_MS; }
finally { sendingEmote = false; }
};
});
const renderer = createActionRenderer(arena); const renderer = createActionRenderer(arena);
const cutscene = createFinishCutscene(() => { tick(); el('.result-banner h2').focus({ preventScroll: true }); }, appearances); const cutscene = createFinishCutscene(() => { tick(); el('.result-banner h2').focus({ preventScroll: true }); }, appearances);
const appearanceSignatures = [0, 1].map(i => appearanceKey(appearances?.[i])); const appearanceSignatures = [0, 1].map(i => appearanceKey(appearances?.[i]));
@@ -26,6 +64,23 @@ export function createMatchView(container: HTMLElement, onAction: (action: Actio
const el = <T extends HTMLElement = HTMLElement>(selector: string) => container.querySelector<T>(selector)!; const el = <T extends HTMLElement = HTMLElement>(selector: string) => container.querySelector<T>(selector)!;
el('.result-banner h2').tabIndex = -1; el('.result-banner h2').tabIndex = -1;
const buttons = Array.from(container.querySelectorAll<HTMLButtonElement>('[data-action]')); const buttons = Array.from(container.querySelectorAll<HTMLButtonElement>('[data-action]'));
buttons.forEach(button => {
const shortcut = ACTION_SHORTCUTS[button.dataset.action as ActionId];
button.setAttribute('aria-keyshortcuts', shortcut);
button.insertAdjacentHTML('beforeend', `<kbd class="action-shortcut" aria-hidden="true">${shortcut}</kbd>`);
});
const onKeyDown = (event: KeyboardEvent) => {
if (event.defaultPrevented || event.repeat || event.ctrlKey || event.altKey || event.metaKey || emoteOpen
|| container.hidden || document.querySelector('dialog[open]')) return;
if (event.target instanceof HTMLElement && (event.target.isContentEditable || event.target.closest('input, textarea, select'))) return;
const button = buttons.find(button => event.code === `Key${ACTION_SHORTCUTS[button.dataset.action as ActionId]}`);
if (!button || button.disabled) return;
tick();
if (button.disabled) return;
event.preventDefault();
button.click();
};
document.addEventListener('keydown', onKeyDown);
buttons.forEach(button => button.addEventListener('click', async () => { buttons.forEach(button => button.addEventListener('click', async () => {
if (busy || !current || current.phase !== 'selecting' || current.ownAction || Date.now() + offset >= current.deadline) return; if (busy || !current || current.phase !== 'selecting' || current.ownAction || Date.now() + offset >= current.deadline) return;
const action = button.dataset.action as ActionId; const action = button.dataset.action as ActionId;
@@ -40,12 +95,7 @@ export function createMatchView(container: HTMLElement, onAction: (action: Actio
if (request === submission) { busy = false; tick(); } if (request === submission) { busy = false; tick(); }
} }
})); }));
el('.leave').onclick = onLeave; el('.home-return').onclick = onLeave; el('.rematch').onclick = onRematch; el('.home-return').onclick = onLeave; el('.rematch').onclick = onRematch;
el('.match-rules').onclick = () => {
el<HTMLDetailsElement>('.match-menu').open = false;
el('.match-menu summary').focus();
onRules();
};
function tick() { function tick() {
if (!current) return; if (!current) return;
const now = Date.now() + offset, s = current; const now = Date.now() + offset, s = current;
@@ -55,6 +105,11 @@ export function createMatchView(container: HTMLElement, onAction: (action: Actio
arena.hidden = !!introduction; arena.hidden = !!introduction;
const revealing = resultReported !== s.matchId && !!s.reveal && now < s.reveal.revealEndsAt; const revealing = resultReported !== s.matchId && !!s.reveal && now < s.reveal.revealEndsAt;
const resultVisible = s.phase === 'finished' && !revealing; const resultVisible = s.phase === 'finished' && !revealing;
const emotesAvailable = !!onEmote && !introduction && s.phase !== 'finished' && s.opponentPresent;
if (!emotesAvailable) closeEmotes();
emoteMenu.hidden = !emoteOpen;
emoteTriggers.forEach(button => { button.hidden = !emotesAvailable || Number(button.dataset.emoteTrigger) !== s.selfIndex; });
emoteMenu.querySelectorAll<HTMLButtonElement>('button').forEach(button => { button.disabled = sendingEmote || Date.now() < emoteReadyAt; });
if (resultVisible && resultReported !== s.matchId) renderer.reset(); if (resultVisible && resultReported !== s.matchId) renderer.reset();
const recovering = !resultVisible && !s.opponentPresent; const recovering = !resultVisible && !s.opponentPresent;
const phase = revealing ? 'revealing' : resultVisible ? 'finished' : s.phase; const phase = revealing ? 'revealing' : resultVisible ? 'finished' : s.phase;
@@ -75,6 +130,7 @@ export function createMatchView(container: HTMLElement, onAction: (action: Actio
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 || recovering; 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('.timeout-chances').textContent = `초읽기 ${Math.max(0, TIMEOUT_CHANCES - (s.timeouts?.[s.selfIndex] ?? 0))}/${TIMEOUT_CHANCES}`;
el('.time-label').textContent = s.phase === 'selecting' && !revealing && !recovering ? `${(remaining / 1000).toFixed(1)}초` : ''; el('.time-label').textContent = s.phase === 'selecting' && !revealing && !recovering ? `${(remaining / 1000).toFixed(1)}초` : '';
el('.time-track').hidden = recovering; el('.time-track').hidden = recovering;
const progress = s.phase === 'countdown' ? remaining / MATCH_COUNTDOWN_MS : s.phase === 'selecting' ? remaining / (s.deadline - s.startsAt) : 0; const progress = s.phase === 'countdown' ? remaining / MATCH_COUNTDOWN_MS : s.phase === 'selecting' ? remaining / (s.deadline - s.startsAt) : 0;
@@ -93,6 +149,13 @@ export function createMatchView(container: HTMLElement, onAction: (action: Actio
}); });
container.querySelectorAll<HTMLElement>('[data-fighter]').forEach((node, i) => { container.querySelectorAll<HTMLElement>('[data-fighter]').forEach((node, i) => {
const self = i === s.selfIndex; const self = i === s.selfIndex;
const bubble = node.querySelector<HTMLElement>('.emote-bubble');
const emote = s.emotes?.[i];
if (bubble) {
const line = emote && emote.expiresAt > now && !resultVisible ? EMOTE_LINES[emote.id]?.[emote.variant] : '';
bubble.hidden = !line;
if (bubble.textContent !== (line ?? '')) bubble.textContent = line ?? '';
}
node.dataset.locked = String(self && !!s.ownAction && phase === 'selecting'); node.dataset.locked = String(self && !!s.ownAction && phase === 'selecting');
if (resultVisible && !s.aborted && s.finished?.reason === 'hit' && s.reveal?.matchId === s.matchId if (resultVisible && !s.aborted && s.finished?.reason === 'hit' && s.reveal?.matchId === s.matchId
&& s.reveal.hitIndex === i && s.reveal.actions[1 - i] === 'beam') node.dataset.ash = 'true'; && s.reveal.hitIndex === i && s.reveal.actions[1 - i] === 'beam') node.dataset.ash = 'true';
@@ -106,6 +169,7 @@ export function createMatchView(container: HTMLElement, onAction: (action: Actio
const f = s.finished; const f = s.finished;
el('.finish-reason').hidden = f.reason === 'hit'; 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';
if (outcome !== 'aborted' && !cutscene.active) sound.play(outcome, `${s.matchId}:result`);
container.dataset.outcome = outcome; container.dataset.outcome = outcome;
arena.dataset.result = outcome; arena.dataset.result = outcome;
el('.result-banner h2').textContent = ({ win: '승리!', loss: '패배', draw: '무승부', aborted: '대전 중단' })[outcome]; el('.result-banner h2').textContent = ({ win: '승리!', loss: '패배', draw: '무승부', aborted: '대전 중단' })[outcome];
@@ -129,6 +193,7 @@ export function createMatchView(container: HTMLElement, onAction: (action: Actio
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) { if (lastId !== state.matchId) {
emoteReadyAt = 0; closeEmotes();
cutscene.reset(); renderer.reset(); lastId = state.matchId; busy = false; submission++; 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]); el('.match-introduction .queue-fighter').innerHTML = fighterArt(state.selfIndex, false, (state.appearances ?? appearances)?.[state.selfIndex]);
} }
@@ -145,6 +210,7 @@ export function createMatchView(container: HTMLElement, onAction: (action: Actio
appearanceSignatures[i] = signature; appearanceSignatures[i] = signature;
} }
node.classList.toggle('self', i === state.selfIndex); node.classList.toggle('self', i === state.selfIndex);
node.querySelector('.fighter-position')!.textContent = i === state.selfIndex ? '▼ 나' : '▼ 상대';
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' ? '봇 캐릭터' : '상대 캐릭터');
}); });
@@ -153,8 +219,8 @@ export function createMatchView(container: HTMLElement, onAction: (action: Actio
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();
cutscene.update(state); cutscene.update(state);
}, destroy() { submission++; clearInterval(interval); cutscene.destroy(); renderer.destroy(); container.innerHTML = ''; current = undefined; }, tick();
}, destroy() { submission++; clearInterval(interval); document.removeEventListener('keydown', onKeyDown); cutscene.destroy(); renderer.destroy(); sound.stop(); container.onpointerdown = null; container.onkeydown = null; container.innerHTML = ''; current = undefined; },
}; };
} }
+14 -28
View File
@@ -1,4 +1,8 @@
/* Match layout only; character poses, projectiles and finish cutscenes stay in game.css. */ /* Match layout only; character poses, projectiles and finish cutscenes stay in game.css. */
.emote-menu { position: absolute; z-index: 16; display: grid; grid-template-columns: repeat(2, 68px); gap: 4px; padding: 6px; background: #fffdf1; border: 1px solid #56615a; }
.emote-menu button { padding: 10px 6px; min-height: 44px; }
.emote-bubble { position: absolute; z-index: 7; bottom: 100%; left: 50%; transform: translateX(-50%); width: max-content; max-width: min(220px, 43vw); padding: 10px 12px; border: 2px solid #36433b; border-radius: 14px; background: #fffdf1; color: #232a2c; font-family: var(--ui-display); font-weight: 400; font-size: clamp(15px, 2vw, 20px); line-height: 1.4; text-align: center; word-break: keep-all; overflow-wrap: anywhere; pointer-events: none; }
.emote-bubble::after { content: ''; position: absolute; top: 100%; left: 50%; border: 7px solid transparent; border-top-color: #36433b; }
body:is([data-screen=match], [data-screen=result]) { body:is([data-screen=match], [data-screen=result]) {
overflow: hidden; overflow: hidden;
background: #d9ddd4; background: #d9ddd4;
@@ -11,7 +15,7 @@ body:is([data-screen=match], [data-screen=result]) main { padding: 0; min-height
#match { #match {
--stage-top: calc(120px + env(safe-area-inset-top)); --stage-top: calc(120px + env(safe-area-inset-top));
--stage-bottom: 24px; --stage-bottom: 24px;
--fighter-size: min(32vw, calc((100cqh - 38px) * .866667), 460px); --fighter-size: min(32vw, calc((100cqh - 62px) * .866667), 460px);
--edge: clamp(20px, 3vw, 56px); --edge: clamp(20px, 3vw, 56px);
--self-position: 26.63%; --self-position: 26.63%;
position: relative; isolation: isolate; flex: 1; width: 100%; max-width: none; position: relative; isolation: isolate; flex: 1; width: 100%; max-width: none;
@@ -43,6 +47,8 @@ body:is([data-screen=match], [data-screen=result]) main { padding: 0; min-height
} }
/* Fighter slots stay fixed so effects and the controls share the same coordinates. */ /* Fighter slots stay fixed so effects and the controls share the same coordinates. */
#match .fighter { position: static; width: 43%; } #match .fighter { position: static; width: 43%; }
#match .fighter-position { margin-bottom: 8px; color: #59665d; font-size: 13px; line-height: 16px; text-align: center; }
#match .fighter.self .fighter-position { color: #242526; font-weight: 700; }
#match .avatar, #match[data-phase=finished] .avatar { #match .avatar, #match[data-phase=finished] .avatar {
width: var(--fighter-size); width: var(--fighter-size);
max-width: none; margin: 0; left: 50%; translate: -50% 0; max-width: none; margin: 0; left: 50%; translate: -50% 0;
@@ -64,34 +70,12 @@ body:is([data-screen=match], [data-screen=result]) main { padding: 0; min-height
transform: translateX(-50%); width: 220px; min-height: 37px; padding: 0 0 13px; transform: translateX(-50%); width: 220px; min-height: 37px; padding: 0 0 13px;
z-index: 12; font-size: 11px; align-items: center; gap: 8px; z-index: 12; font-size: 11px; align-items: center; gap: 8px;
} }
#match .timeout-chances { position: absolute; top: 100%; left: 50%; transform: translateX(-50%); font-size: 11px; white-space: nowrap; }
#match .time-label { font-size: 16px; white-space: nowrap; } #match .time-label { font-size: 16px; white-space: nowrap; }
#match .time-track { bottom: 0; height: 4px; border-radius: 0; background: #39493c20; } #match .time-track { bottom: 0; height: 4px; border-radius: 0; background: #39493c20; }
#match .time-track span { border-radius: 0; background: #a85039; } #match .time-track span { border-radius: 0; background: #a85039; }
#match[data-locked=true] .time-track span { background: #557b85; } #match[data-locked=true] .time-track span { background: #557b85; }
#match .timing[data-urgent=true] .time-track span { background: #c04128; } #match .timing[data-urgent=true] .time-track span { background: #c04128; }
#match .match-menu {
pointer-events: auto;
position: absolute; top: calc(25px + env(safe-area-inset-top));
right: max(var(--edge), env(safe-area-inset-right));
}
#match .match-menu summary {
display: grid; place-items: center; list-style: none; cursor: pointer;
min-width: 52px; min-height: 44px; padding: 8px 12px;
border: 1px solid #52615655; border-radius: 3px; background: #f4f0e3;
font-size: 12px; font-weight: 700;
}
#match .match-menu summary::-webkit-details-marker { display: none; }
#match .match-menu summary:focus-visible { outline: 3px solid #266acc; outline-offset: 4px; }
#match .match-menu[open] summary { background: #303c38; color: #fff9e9; }
#match .match-menu-items {
position: absolute; top: calc(100% + 8px); right: 0; width: 132px;
padding: 6px; background: #f4f0e3; border: 1px solid #697568;
box-shadow: 3px 4px 0 #303c3820;
}
#match .match-menu-items button {
display: block; width: 100%; min-height: 44px; border: 0; border-radius: 0;
color: #303c38; text-align: left;
}
#match .duel-cue, #match[data-phase=countdown] .duel-cue { top: 43%; } #match .duel-cue, #match[data-phase=countdown] .duel-cue { top: 43%; }
#match:not([data-phase=countdown]) .versus { display: none; } #match:not([data-phase=countdown]) .versus { display: none; }
#match[data-phase=countdown] .countdown-number { border-radius: 3px; } #match[data-phase=countdown] .countdown-number { border-radius: 3px; }
@@ -119,6 +103,10 @@ body:is([data-screen=match], [data-screen=result]) main { padding: 0; min-height
grid-row: span 2; align-self: center; width: 28px; height: 28px; grid-row: span 2; align-self: center; width: 28px; height: 28px;
} }
#match .action-buttons strong { font-size: 13px; white-space: nowrap; } #match .action-buttons strong { font-size: 13px; white-space: nowrap; }
#match .action-shortcut { display: none; font: 600 10px/1.2 var(--ui-body, sans-serif); border: 1px solid currentColor; padding: 1px 4px; }
@media (orientation: landscape) {
#match .action-shortcut { display: inline-block; }
}
#match .action-buttons small { color: #58604f; font-size: 10px; } #match .action-buttons small { color: #58604f; font-size: 10px; }
#match .action-buttons button:hover:not(:disabled) { background: #fff9e9; border-color: var(--action-color); transform: translateY(-2px); } #match .action-buttons button:hover:not(:disabled) { background: #fff9e9; border-color: var(--action-color); transform: translateY(-2px); }
#match .action-buttons button:active:not(:disabled) { transform: translateY(1px); } #match .action-buttons button:active:not(:disabled) { transform: translateY(1px); }
@@ -165,7 +153,7 @@ body:is([data-screen=match], [data-screen=result]) #reconnect {
--edge: 16px; --stage-bottom: 16px; --edge: 16px; --stage-bottom: 16px;
--self-position: 25.09%; --self-position: 25.09%;
--stage-top: calc(100px + env(safe-area-inset-top)); --stage-top: calc(100px + env(safe-area-inset-top));
--fighter-size: min(60vw, calc((100cqh - 38px) * .866667), 360px); --fighter-size: min(48vw, calc((100cqh - 62px) * .866667), 288px);
} }
#match[data-self="1"] { --self-position: 74.91%; } #match[data-self="1"] { --self-position: 74.91%; }
#match[data-phase=finished] { --stage-top: calc(210px + env(safe-area-inset-top)); } #match[data-phase=finished] { --stage-top: calc(210px + env(safe-area-inset-top)); }
@@ -173,7 +161,6 @@ body:is([data-screen=match], [data-screen=result]) #reconnect {
#match .fighter { width: 47%; } #match .fighter { width: 47%; }
#match .match-title { top: calc(24px + env(safe-area-inset-top)); left: var(--edge); transform: none; } #match .match-title { top: calc(24px + env(safe-area-inset-top)); left: var(--edge); transform: none; }
#match .turn-heading { font-size: 16px; } #match .turn-heading { font-size: 16px; }
#match .match-menu { top: calc(12px + env(safe-area-inset-top)); }
#match .energy { gap: 3px; } #match .energy { gap: 3px; }
#match .energy-count { font-size: 25px; } #match .energy-count { font-size: 25px; }
#match .energy > svg { width: 18px; height: 18px; } #match .energy > svg { width: 18px; height: 18px; }
@@ -206,13 +193,12 @@ body:is([data-screen=match], [data-screen=result]) #reconnect {
--edge: max(16px, env(safe-area-inset-left)); --stage-bottom: 8px; --edge: max(16px, env(safe-area-inset-left)); --stage-bottom: 8px;
--stage-top: calc(78px + env(safe-area-inset-top)); --stage-top: calc(78px + env(safe-area-inset-top));
--self-position: 28.34%; --self-position: 28.34%;
--fighter-size: min(26vw, calc((100cqh - 38px) * .866667), 260px); --fighter-size: min(26vw, calc((100cqh - 62px) * .866667), 260px);
} }
#match[data-self="1"] { --self-position: 71.66%; } #match[data-self="1"] { --self-position: 71.66%; }
#match[data-phase=finished] { --stage-top: calc(96px + env(safe-area-inset-top)); } #match[data-phase=finished] { --stage-top: calc(96px + env(safe-area-inset-top)); }
#match .arena { padding-inline: 12vw; } #match .arena { padding-inline: 12vw; }
#match .match-title { top: calc(12px + env(safe-area-inset-top)); left: 50%; transform: translateX(-50%); } #match .match-title { top: calc(12px + env(safe-area-inset-top)); left: 50%; transform: translateX(-50%); }
#match .match-menu { top: calc(10px + env(safe-area-inset-top)); right: max(12px, env(safe-area-inset-right)); }
#match .timing { top: calc(38px + env(safe-area-inset-top)); width: 120px; min-height: 33px; flex-direction: row; padding-bottom: 8px; } #match .timing { top: calc(38px + env(safe-area-inset-top)); width: 120px; min-height: 33px; flex-direction: row; padding-bottom: 8px; }
#match .player-controls { width: calc(50% - 24px); margin-bottom: max(8px, env(safe-area-inset-bottom)); } #match .player-controls { width: calc(50% - 24px); margin-bottom: max(8px, env(safe-area-inset-bottom)); }
#match .energy { padding-bottom: 4px; } #match .energy { padding-bottom: 4px; }
+30
View File
@@ -0,0 +1,30 @@
import { io, type Socket } from 'socket.io-client';
import type { GameSessionAuth } from '../shared/p2p-types.js';
import { PRESENCE_RETRY_MS } from '../../constants/presence.js';
export function createPresence(counter: HTMLElement) {
let socket: Socket | undefined, sessionId = '';
let retry: ReturnType<typeof setTimeout> | undefined;
const unknown = () => { counter.textContent = '접속 확인 중'; };
window.addEventListener('pagehide', () => { clearTimeout(retry); socket?.disconnect(); unknown(); });
window.addEventListener('pageshow', () => { socket?.connect(); });
return {
connect(auth: GameSessionAuth) {
if (socket && sessionId === auth.gameSessionId) { socket.connect(); return; }
clearTimeout(retry); socket?.removeAllListeners(); socket?.disconnect(); unknown();
sessionId = auth.gameSessionId;
socket = io('/presence', { auth: { gameSessionId: auth.gameSessionId, token: auth.token }, forceNew: true, autoConnect: false });
socket.on('presence:count', (count: unknown) => {
if (Number.isSafeInteger(count) && (count as number) >= 0) counter.textContent = `현재 ${count}명 접속 중`;
});
socket.on('connect', () => clearTimeout(retry));
socket.on('disconnect', unknown);
socket.on('connect_error', error => {
unknown(); clearTimeout(retry);
// Middleware errors do not trigger Socket.IO's transport reconnection loop.
if (error.message !== 'INVALID_SESSION') retry = setTimeout(() => socket?.connect(), PRESENCE_RETRY_MS);
});
socket.connect();
},
};
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 392 KiB

+86
View File
@@ -0,0 +1,86 @@
import { SOUNDS, SOUND_EVENT_HISTORY, SOUND_MASTER_VOLUME, SOUND_MAX_VOICES, SOUND_MUTED_KEY, type SoundId, type SoundPart } from '../../constants/sound.js';
export function createSound(options: { context?: () => AudioContext; storage?: Pick<Storage, 'getItem' | 'setItem'> } = {}) {
let context: AudioContext | undefined, master: GainNode | undefined, muted = false;
const events = new Set<string>(), voices = new Set<AudioScheduledSourceNode>();
const storage = () => options.storage ?? globalThis.localStorage;
try { muted = storage()?.getItem(SOUND_MUTED_KEY) === 'true'; } catch { /* Optional preference storage. */ }
function stop() {
for (const voice of voices) { try { voice.stop(); } catch { /* Already ended. */ } }
voices.clear();
}
return {
get muted() { return muted; },
async unlock() {
if (muted) return;
try {
if (!context) {
context = options.context?.() ?? new AudioContext();
master = context.createGain(); master.gain.value = SOUND_MASTER_VOLUME;
const compressor = context.createDynamicsCompressor();
master.connect(compressor); compressor.connect(context.destination);
}
if (context.state === 'suspended') await context.resume();
} catch { /* Audio support/autoplay failures must not interrupt gameplay. */ }
},
setMuted(value: boolean) {
muted = value; if (muted) stop();
try { storage()?.setItem(SOUND_MUTED_KEY, String(value)); } catch { /* Keep the in-memory preference. */ }
},
stop,
play(id: SoundId, key?: string, pan = 0) {
if (key) {
if (events.has(key)) return;
events.add(key);
if (events.size > SOUND_EVENT_HISTORY) events.delete(events.values().next().value!);
}
if (muted || !context || !master || context.state !== 'running' || (typeof document !== 'undefined' && document.hidden)) return;
const ctx = context;
for (const part of SOUNDS[id] as readonly SoundPart[]) {
if (voices.size >= SOUND_MAX_VOICES) break;
const at = ctx.currentTime + (part.delay ?? 0), end = at + part.duration;
const envelope = ctx.createGain(), panner = ctx.createStereoPanner();
panner.pan.value = Math.max(-1, Math.min(1, pan));
envelope.gain.setValueAtTime(0, at);
envelope.gain.linearRampToValueAtTime(part.volume, at + Math.min(.012, part.duration / 4));
envelope.gain.exponentialRampToValueAtTime(.0001, end);
envelope.gain.linearRampToValueAtTime(0, end + .01);
envelope.connect(panner); panner.connect(master);
let source: AudioScheduledSourceNode, filter: BiquadFilterNode | undefined;
if (part.wave === 'noise') {
const buffer = ctx.createBuffer(1, Math.ceil(ctx.sampleRate * (part.duration + .02)), ctx.sampleRate);
const data = buffer.getChannelData(0);
for (let i = 0; i < data.length; i++) data[i] = Math.random() * 2 - 1;
const noise = ctx.createBufferSource(); noise.buffer = buffer; source = noise;
filter = ctx.createBiquadFilter(); filter.type = 'lowpass';
filter.frequency.setValueAtTime(part.from, at); filter.frequency.exponentialRampToValueAtTime(part.to, end);
source.connect(filter); filter.connect(envelope);
} else {
const oscillator = ctx.createOscillator(); source = oscillator; oscillator.type = part.wave;
oscillator.frequency.setValueAtTime(part.from, at); oscillator.frequency.exponentialRampToValueAtTime(part.to, end);
source.connect(envelope);
}
voices.add(source);
source.onended = () => { voices.delete(source); source.disconnect(); filter?.disconnect(); envelope.disconnect(); panner.disconnect(); };
source.start(at); source.stop(end + .02);
}
},
};
}
export const sound = createSound();
export function installSoundControls() {
const button = document.getElementById('sound-toggle') as HTMLButtonElement;
const refresh = () => { button.textContent = sound.muted ? '소리 켜기' : '소리 끄기'; button.setAttribute('aria-pressed', String(!sound.muted)); };
refresh();
button.onclick = () => { sound.setMuted(!sound.muted); refresh(); void sound.unlock(); };
document.addEventListener('pointerdown', () => { void sound.unlock(); }, { passive: true });
document.addEventListener('keydown', () => { void sound.unlock(); });
document.addEventListener('click', event => {
const target = event.target instanceof Element ? event.target.closest('button, summary') : null;
if (!target || target.id === 'sound-toggle' || target.matches(':disabled')) return;
void sound.unlock().then(() => sound.play('click'));
}, true);
document.addEventListener('visibilitychange', () => { if (document.hidden) sound.stop(); });
window.addEventListener('pagehide', () => sound.stop());
}
+25 -13
View File
@@ -1,4 +1,23 @@
/* Import after game.css, match.css and home.css. */ /* Import after game.css, match.css and home.css. */
body.player-app > .online-count {
position: fixed;
top: max(16px, env(safe-area-inset-top));
right: max(20px, env(safe-area-inset-right));
z-index: 50;
color: #45534a;
font-size: 12px;
font-variant-numeric: tabular-nums;
white-space: nowrap;
pointer-events: none;
}
body.player-app #match .emote-trigger,
body.player-app #match .emote-trigger:hover:not(:disabled),
body.player-app #match .emote-trigger:active:not(:disabled) {
position: absolute; inset: 0; z-index: 8; width: 100%; height: 100%;
margin: 0; padding: 0; border: 0; border-radius: 0; background: transparent;
box-shadow: none; transform: none; cursor: pointer;
}
body.player-app #match .emote-trigger:focus-visible { outline: 3px solid #266acc; outline-offset: 3px; }
@font-face { @font-face {
font-family: 'Black Han Sans'; font-family: 'Black Han Sans';
src: url('./fonts/BlackHanSans-Regular.ttf') format('truetype'); src: url('./fonts/BlackHanSans-Regular.ttf') format('truetype');
@@ -26,8 +45,7 @@ body.player-app #match .action-buttons strong {
} }
body.player-app button, body.player-app button,
body.player-app #match button, body.player-app #match button {
body.player-app #match .match-menu summary {
border: 2px solid var(--ink); border: 2px solid var(--ink);
border-radius: 0; border-radius: 0;
background: var(--ink); background: var(--ink);
@@ -37,8 +55,7 @@ body.player-app #match .match-menu summary {
} }
body.player-app button.primary, body.player-app button.primary,
body.player-app #match button.primary, body.player-app #match button.primary {
body.player-app #match .action-buttons button[data-action=charge] {
border-radius: 0; border-radius: 0;
background: var(--ui-orange); background: var(--ui-orange);
color: var(--ink); color: var(--ink);
@@ -46,8 +63,7 @@ body.player-app #match .action-buttons button[data-action=charge] {
} }
body.player-app button:hover:not(:disabled), body.player-app button:hover:not(:disabled),
body.player-app #match button:hover:not(:disabled), body.player-app #match button:hover:not(:disabled) {
body.player-app #match .match-menu summary:hover {
background: #ffbd43; background: #ffbd43;
border-color: var(--ink); border-color: var(--ink);
color: var(--ink); color: var(--ink);
@@ -55,8 +71,7 @@ body.player-app #match .match-menu summary:hover {
} }
body.player-app button:active:not(:disabled), body.player-app button:active:not(:disabled),
body.player-app #match button:active:not(:disabled), body.player-app #match button:active:not(:disabled) {
body.player-app #match .match-menu summary:active {
border-width: 2px; border-width: 2px;
box-shadow: none; box-shadow: none;
transform: translate(3px, 3px); transform: translate(3px, 3px);
@@ -98,15 +113,12 @@ body.player-app #waiting-leave { margin-top: 12px; }
body.player-app #match .action-buttons { background: transparent; border: 0; border-radius: 0; box-shadow: none; } body.player-app #match .action-buttons { background: transparent; border: 0; border-radius: 0; box-shadow: none; }
body.player-app #match .action-buttons button { box-shadow: 3px 3px 0 #24252640; } body.player-app #match .action-buttons button { box-shadow: 3px 3px 0 #24252640; }
body.player-app #match .action-buttons :is(button[data-action=charge], button.chosen) { box-shadow: 3px 3px 0 var(--ink); } body.player-app #match .action-buttons button.chosen { box-shadow: 3px 3px 0 var(--ink); }
body.player-app #match .action-buttons button:disabled:not(.chosen) { box-shadow: none; } body.player-app #match .action-buttons button:disabled:not(.chosen) { box-shadow: none; }
body.player-app #match .action-buttons button[data-action=charge]:disabled:not(.chosen) { background: #e4e4e4; color: #666; }
body.player-app #match .action-buttons small { color: inherit; font-family: var(--ui-body); font-weight: 400; } body.player-app #match .action-buttons small { color: inherit; font-family: var(--ui-body); font-weight: 400; }
body.player-app #match .action-buttons .action-symbol { color: inherit; opacity: 1; filter: none; } body.player-app #match .action-buttons .action-symbol { color: inherit; opacity: 1; filter: none; }
body.player-app #match .energy > svg { color: var(--ink); } body.player-app #match .energy > svg { color: var(--ink); }
body.player-app #match .chosen-mark { background: var(--ink); border-color: #fff; color: #fff; border-radius: 0; } body.player-app #match .chosen-mark { background: var(--ink); border-color: #fff; color: #fff; border-radius: 0; }
body.player-app #match .match-menu-items { background: #fff; border: 2px solid var(--ink); border-radius: 0; box-shadow: 4px 4px 0 #24252640; padding: 8px; }
body.player-app #match .match-menu-items button + button { margin-top: 8px; }
body.player-app dialog:not(.finish-cutscene) { body.player-app dialog:not(.finish-cutscene) {
padding: 24px; padding: 24px;
@@ -128,5 +140,5 @@ body.player-app #contact-content { overflow-wrap: anywhere; }
} }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
body.player-app button, body.player-app #match button, body.player-app #match .match-menu summary { transition: none; } body.player-app button, body.player-app #match button { transition: none; }
} }
+5 -4
View File
@@ -1,5 +1,5 @@
import type { Db } from 'mongodb'; import type { Db } from 'mongodb';
import { RETENTION_MS } from '../../constants/analytics.js'; import { PRESENCE_RETENTION_MS, RETENTION_MS } from '../../constants/analytics.js';
import type { AnalyticsEvent, AnalyticsFilters, Cohort, Ratio, VisitSession } from '../shared/analytics-types.js'; import type { AnalyticsEvent, AnalyticsFilters, Cohort, Ratio, VisitSession } from '../shared/analytics-types.js';
import type { PlayerDocument } from './stats-store.js'; import type { PlayerDocument } from './stats-store.js';
export const kstDay = (date: Date) => new Date(+date + 9 * 3600000).toISOString().slice(0, 10); export const kstDay = (date: Date) => new Date(+date + 9 * 3600000).toISOString().slice(0, 10);
@@ -101,13 +101,14 @@ export async function getAnalyticsReport(db: Db, filters: AnalyticsFilters, now:
const counts = (rows: AnalyticsEvent[], field: 'code' | 'action' | 'screen' | 'outcome') => Object.entries(rows.reduce<Record<string, number>>((out, e) => { const k = e[field] ?? 'unknown'; out[k] = (out[k] ?? 0) + 1; return out; }, {})).map(([name, count]) => ({ name, count })); const counts = (rows: AnalyticsEvent[], field: 'code' | 'action' | 'screen' | 'outcome') => Object.entries(rows.reduce<Record<string, number>>((out, e) => { const k = e[field] ?? 'unknown'; out[k] = (out[k] ?? 0) + 1; return out; }, {})).map(([name, count]) => ({ name, count }));
const first = players.filter(p => p.firstVisitAt && period(p.firstVisitAt) && sessions.some(s => s.playerId === p._id && qualifies(s.playerId, s.sessionId)) && allEvents.some(e => e.playerId === p._id && e.eventType === 'page_view')); const first = players.filter(p => p.firstVisitAt && period(p.firstVisitAt) && sessions.some(s => s.playerId === p._id && qualifies(s.playerId, s.sessionId)) && allEvents.some(e => e.playerId === p._id && e.eventType === 'page_view'));
const visitActivity = sessions.filter(s => qualifies(s.playerId, s.sessionId)).flatMap(s => (s.activeDays ?? []).map(day => ({ playerId: s.playerId, day }))); const visitActivity = sessions.filter(s => qualifies(s.playerId, s.sessionId)).flatMap(s => (s.activeDays ?? []).map(day => ({ playerId: s.playerId, day })));
const sampleStart = new Date(Math.max(+start, +now - PRESENCE_RETENTION_MS));
const samples = await db.collection('analytics_presence_samples').aggregate([ const samples = await db.collection('analytics_presence_samples').aggregate([
{ $match: { occurredAt: { $gte: start, $lt: end }, environment: 'production', source: 'activity_reports' } }, { $match: { occurredAt: { $gte: sampleStart, $lt: end }, environment: 'production', source: 'activity_reports' } },
{ $group: { _id: null, count: { $sum: 1 }, active: { $max: '$active' } } }, { $project: { _id: 0 } }, { $group: { _id: null, count: { $sum: { $ifNull: ['$sampleCount', 1] } }, active: { $max: '$active' } } }, { $project: { _id: 0 } },
]).toArray(); ]).toArray();
const metadata = await db.collection('analytics_metadata').findOne({ _id: 'production' as any }); const metadata = await db.collection('analytics_metadata').findOne({ _id: 'production' as any });
const quality = await db.collection('analytics_presence_samples').aggregate([ const quality = await db.collection('analytics_presence_samples').aggregate([
{ $match: { occurredAt: { $gte: start, $lt: end }, environment: 'production', source: 'activity_reports' } }, { $match: { occurredAt: { $gte: sampleStart, $lt: end }, environment: 'production', source: 'activity_reports' } },
{ $group: { _id: '$restartedAt', failures: { $max: '$failures' }, dropped: { $max: '$dropped' }, rateLimited: { $max: '$rateLimited' } } }, { $group: { _id: '$restartedAt', failures: { $max: '$failures' }, dropped: { $max: '$dropped' }, rateLimited: { $max: '$rateLimited' } } },
{ $group: { _id: null, failures: { $sum: '$failures' }, dropped: { $sum: '$dropped' }, rateLimited: { $sum: '$rateLimited' } } }, { $project: { _id: 0 } }, { $group: { _id: null, failures: { $sum: '$failures' }, dropped: { $sum: '$dropped' }, rateLimited: { $sum: '$rateLimited' } } }, { $project: { _id: 0 } },
]).toArray(); ]).toArray();
+6 -3
View File
@@ -1,6 +1,6 @@
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { MongoServerError, type Db } from 'mongodb'; import { MongoServerError, type Db } from 'mongodb';
import { ACTIVE_WINDOW_MS, RETENTION_MS, VISIT_SESSION_MS } from '../../constants/analytics.js'; import { ACTIVE_WINDOW_MS, PRESENCE_RETENTION_MS, RETENTION_MS, VISIT_SESSION_MS } from '../../constants/analytics.js';
import type { AnalyticsEvent, Presence, VisitMetadata, VisitSession } from '../shared/analytics-types.js'; import type { AnalyticsEvent, Presence, VisitMetadata, VisitSession } from '../shared/analytics-types.js';
import type { PlayerDocument } from './stats-store.js'; import type { PlayerDocument } from './stats-store.js';
import { kstDay } from './analytics-report.js'; import { kstDay } from './analytics-report.js';
@@ -8,7 +8,7 @@ export interface AnalyticsStore {
db: Db; db: Db;
touch(playerId: string, now: Date, visible: boolean, metadata: VisitMetadata, metadataFinal?: boolean): Promise<VisitSession>; touch(playerId: string, now: Date, visible: boolean, metadata: VisitMetadata, metadataFinal?: boolean): Promise<VisitSession>;
insert(event: AnalyticsEvent): Promise<void>; insert(event: AnalyticsEvent): Promise<void>;
sample(now: Date, presence: Presence, environment: string, quality?: { restartedAt: Date; failures: number; dropped: number; rateLimited: number }): Promise<void>; sample(now: Date, presence: Presence, environment: string, quality?: { restartedAt: Date; failures: number; dropped: number; rateLimited: number }, sampleCount?: number): Promise<void>;
cleanup(now: Date): Promise<void>; cleanup(now: Date): Promise<void>;
} }
export async function createAnalyticsStore(db: Db): Promise<AnalyticsStore> { export async function createAnalyticsStore(db: Db): Promise<AnalyticsStore> {
@@ -21,6 +21,9 @@ export async function createAnalyticsStore(db: Db): Promise<AnalyticsStore> {
await sessions.createIndex({ sessionId: 1 }, { unique: true }); await sessions.createIndex({ sessionId: 1 }, { unique: true });
for (const field of ['eventType', 'playerId', 'sessionId']) await events.createIndex({ [field]: 1, occurredAt: 1 }); for (const field of ['eventType', 'playerId', 'sessionId']) await events.createIndex({ [field]: 1, occurredAt: 1 });
await samples.createIndex({ occurredAt: 1 }); await samples.createIndex({ occurredAt: 1 });
// Shorten existing samples' expiry too; events and visit sessions keep their retention.
await samples.updateMany({ occurredAt: { $type: 'date' }, $expr: { $gt: ['$expiresAt', { $add: ['$occurredAt', PRESENCE_RETENTION_MS] }] } },
[{ $set: { expiresAt: { $add: ['$occurredAt', PRESENCE_RETENTION_MS] } } }]);
await players.createIndex({ 'visit.expiresAt': 1 }); await players.createIndex({ 'visit.expiresAt': 1 });
const cleanup = async (now: Date) => { await players.updateMany({ 'visit.expiresAt': { $lte: now } }, { $unset: { visit: '' } }); }; const cleanup = async (now: Date) => { await players.updateMany({ 'visit.expiresAt': { $lte: now } }, { $unset: { visit: '' } }); };
await cleanup(new Date()); await cleanup(new Date());
@@ -59,6 +62,6 @@ export async function createAnalyticsStore(db: Db): Promise<AnalyticsStore> {
await db.collection<{ _id: string; firstCollectedAt: Date }>('analytics_metadata').updateOne({ _id: event.environment }, { $min: { firstCollectedAt: event.occurredAt } }, { upsert: true }); await db.collection<{ _id: string; firstCollectedAt: Date }>('analytics_metadata').updateOne({ _id: event.environment }, { $min: { firstCollectedAt: event.occurredAt } }, { upsert: true });
if (event.eventType === 'page_view' && event.playerId) await players.updateOne({ _id: event.playerId }, { $min: { firstVisitAt: event.occurredAt } }); if (event.eventType === 'page_view' && event.playerId) await players.updateOne({ _id: event.playerId }, { $min: { firstVisitAt: event.occurredAt } });
}, },
async sample(now, presence, environment, quality) { await samples.insertOne({ occurredAt: now, active: presence.active, source: presence.source, environment, ...(quality ?? {}), expiresAt: new Date(+now + RETENTION_MS) }); }, async sample(now, presence, environment, quality, sampleCount = 1) { await samples.insertOne({ occurredAt: now, active: presence.active, source: presence.source, sampleCount, environment, ...(quality ?? {}), expiresAt: new Date(+now + PRESENCE_RETENTION_MS) }); },
}; };
} }
+25 -4
View File
@@ -1,4 +1,4 @@
import { ACTIVITY_INTERVAL_MS, ACTIVE_WINDOW_MS, CAMPAIGN_VALUES, CLIENT_ERROR_CODES, CLIENT_EVENTS, MAX_EVENTS_PER_MINUTE, PRESENCE_INTERVAL_MS, RETENTION_SWEEP_MS, RETRY_INTERVAL_MS, RETRY_MAX_AGE_MS, RETRY_QUEUE_LIMIT, SCREENS, UI_ACTIONS } from '../../constants/analytics.js'; import { ACTIVITY_INTERVAL_MS, ACTIVE_WINDOW_MS, CAMPAIGN_VALUES, CLIENT_ERROR_CODES, CLIENT_EVENTS, MAX_EVENTS_PER_MINUTE, PRESENCE_INTERVAL_MS, PRESENCE_SAVE_INTERVAL_MS, RETENTION_SWEEP_MS, RETRY_INTERVAL_MS, RETRY_MAX_AGE_MS, RETRY_QUEUE_LIMIT, SCREENS, UI_ACTIONS } from '../../constants/analytics.js';
import { APP_VERSION } from '../../constants/gameplay.js'; import { APP_VERSION } from '../../constants/gameplay.js';
import type { AnalyticsEvent, Presence, VisitMetadata, VisitSession } from '../shared/analytics-types.js'; import type { AnalyticsEvent, Presence, VisitMetadata, VisitSession } from '../shared/analytics-types.js';
import type { AnalyticsStore } from './analytics-store.js'; import type { AnalyticsStore } from './analytics-store.js';
@@ -23,9 +23,12 @@ export class Analytics {
queue: Job[] = []; running = false; closed = false; timer?: any; sampleTimer?: any; queue: Job[] = []; running = false; closed = false; timer?: any; sampleTimer?: any;
httpRequests = 0; httpRequests = 0;
lastCleanup = 0; lastCleanup = 0;
private sampleWindowStartedAt: number;
private pendingSample?: { at: number; presence: Presence; count: number };
windows = new Map<string, { at: number; count: number }>(); windows = new Map<string, { at: number; count: number }>();
quality = { restartedAt: new Date(), lastSuccessAt: null as Date | null, failures: 0, dropped: 0, rateLimited: 0, retryFailures: 0, resultSaveFailures: 0 }; quality = { restartedAt: new Date(), lastSuccessAt: null as Date | null, failures: 0, dropped: 0, rateLimited: 0, retryFailures: 0, resultSaveFailures: 0 };
constructor(private store: AnalyticsStore, private clock: Clock, private environment = 'production') { constructor(private store: AnalyticsStore, private clock: Clock, private environment = 'production') {
this.sampleWindowStartedAt = clock.now();
this.quality.restartedAt = new Date(clock.now()); this.scheduleSample(); this.quality.restartedAt = new Date(clock.now()); this.scheduleSample();
} }
private activityState(p: AnalyticsIdentity) { private activityState(p: AnalyticsIdentity) {
@@ -64,7 +67,7 @@ export class Analytics {
if (data.eventType === 'client_error' && !CLIENT_ERROR_CODES.includes(data.code as any)) reject('INVALID_ANALYTICS_EVENT', '오류 코드를 확인하세요.'); if (data.eventType === 'client_error' && !CLIENT_ERROR_CODES.includes(data.code as any)) reject('INVALID_ANALYTICS_EVENT', '오류 코드를 확인하세요.');
const gameSummary = data.eventType === 'game_started' || data.eventType === 'game_finished'; const gameSummary = data.eventType === 'game_started' || data.eventType === 'game_finished';
if (gameSummary && (!validId(data.matchId) || !['bot', 'friend'].includes(String(data.mode)))) reject('INVALID_ANALYTICS_EVENT', '대전을 확인하세요.'); if (gameSummary && (!validId(data.matchId) || !['bot', 'friend'].includes(String(data.mode)))) reject('INVALID_ANALYTICS_EVENT', '대전을 확인하세요.');
if (data.eventType === 'game_finished' && (!['win', 'loss', 'draw', 'aborted'].includes(String(data.outcome)) || !['hit', 'timeout', 'double_timeout', 'forfeit', 'connection_failed', 'protocol_error'].includes(String(data.reason)))) reject('INVALID_ANALYTICS_EVENT', '결과를 확인하세요.'); if (data.eventType === 'game_finished' && (!['win', 'loss', 'draw', 'aborted'].includes(String(data.outcome)) || !['hit', 'timeout', 'double_timeout', 'forfeit', 'disconnect', 'double_disconnect', 'connection_failed', 'protocol_error'].includes(String(data.reason)))) reject('INVALID_ANALYTICS_EVENT', '결과를 확인하세요.');
const state = this.activityState(p); const state = this.activityState(p);
if (data.eventType === 'page_view' && !state.visit?.metadataReady) state.metadata = normalizeMetadata(data.metadata); if (data.eventType === 'page_view' && !state.visit?.metadataReady) state.metadata = normalizeMetadata(data.metadata);
const id = gameSummary && p.gameSessionId ? `game:${p.gameSessionId}:${data.matchId}:${data.eventType}` : `${p.playerId}:${data.eventId}`; const id = gameSummary && p.gameSessionId ? `game:${p.gameSessionId}:${data.matchId}:${data.eventType}` : `${p.playerId}:${data.eventId}`;
@@ -127,10 +130,23 @@ export class Analytics {
source: 'activity_reports', observedAt: new Date(this.clock.now()), lastActivityAt: values.length ? new Date(Math.max(...values.map(s => s.lastAt))) : null }; source: 'activity_reports', observedAt: new Date(this.clock.now()), lastActivityAt: values.length ? new Date(Math.max(...values.map(s => s.lastAt))) : null };
} }
status() { return { ...this.quality, queued: this.queue.length, httpRequests: this.httpRequests, environment: this.environment, incomplete: !!(this.quality.dropped || this.quality.failures || this.quality.rateLimited || this.queue.length), now: new Date(this.clock.now()) }; } status() { return { ...this.quality, queued: this.queue.length, httpRequests: this.httpRequests, environment: this.environment, incomplete: !!(this.quality.dropped || this.quality.failures || this.quality.rateLimited || this.queue.length), now: new Date(this.clock.now()) }; }
private takeSampleWrite() {
const sample = this.pendingSample;
if (!sample) return;
this.pendingSample = undefined;
const quality = { restartedAt: this.quality.restartedAt, failures: this.quality.failures, dropped: this.quality.dropped, rateLimited: this.quality.rateLimited };
return { at: sample.at, run: () => this.store.sample(new Date(sample.at), sample.presence, this.environment, quality, sample.count) };
}
private scheduleSample() { private scheduleSample() {
this.sampleTimer = this.clock.setTimeout(() => { this.sampleTimer = this.clock.setTimeout(() => {
const at = this.clock.now(), presence = this.live(), quality = { restartedAt: this.quality.restartedAt, failures: this.quality.failures, dropped: this.quality.dropped, rateLimited: this.quality.rateLimited }; const at = this.clock.now(), presence = this.live();
void this.enqueue(`sample:${at}`, at, () => this.store.sample(new Date(at), presence, this.environment, quality)).catch(() => {}); const previous = this.pendingSample;
this.pendingSample = { at, presence: { ...presence, active: Math.max(previous?.presence.active ?? 0, presence.active) }, count: (previous?.count ?? 0) + 1 };
if (at - this.sampleWindowStartedAt >= PRESENCE_SAVE_INTERVAL_MS) {
const write = this.takeSampleWrite()!;
void this.enqueue(`sample:${write.at}`, write.at, write.run).catch(() => {});
this.sampleWindowStartedAt = at;
}
if (at - this.lastCleanup >= RETENTION_SWEEP_MS) { if (at - this.lastCleanup >= RETENTION_SWEEP_MS) {
this.lastCleanup = at; void this.enqueue(`cleanup:${at}`, at, () => this.store.cleanup(new Date(at))).catch(() => {}); this.lastCleanup = at; void this.enqueue(`cleanup:${at}`, at, () => this.store.cleanup(new Date(at))).catch(() => {});
} }
@@ -143,6 +159,11 @@ export class Analytics {
this.closed = true; this.clock.clearTimeout(this.timer); this.clock.clearTimeout(this.sampleTimer); this.closed = true; this.clock.clearTimeout(this.timer); this.clock.clearTimeout(this.sampleTimer);
for (const j of this.queue) j.waiters.forEach(w => w.reject()); for (const j of this.queue) j.waiters.forEach(w => w.reject());
while (this.running) await new Promise(r => setTimeout(r, 5)); while (this.running) await new Promise(r => setTimeout(r, 5));
const write = this.takeSampleWrite();
if (write) {
try { await write.run(); }
catch { this.quality.failures++; this.quality.dropped++; }
}
this.queue = []; this.states.clear(); this.queue = []; this.states.clear();
} }
} }
+12
View File
@@ -12,11 +12,13 @@ import { createGameSessionStore } from './game-session-store.js';
import { createRoomStore } from './rooms.js'; import { createRoomStore } from './rooms.js';
import { registerAnalyticsApi, registerGameApi, registerRoomApi } from './game-api.js'; import { registerAnalyticsApi, registerGameApi, registerRoomApi } from './game-api.js';
import { registerSignaling, type RtcServerOptions } from './signaling.js'; import { registerSignaling, type RtcServerOptions } from './signaling.js';
import { registerPresence } from './presence.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';
import { createGlobalSettingsStore } from './global-settings.js'; import { createGlobalSettingsStore } from './global-settings.js';
import { gameRoute } from './game-api.js'; import { gameRoute } from './game-api.js';
import { createHttpOptions, registerHttpOperations, logSafeError } from './http-operations.js'; import { createHttpOptions, registerHttpOperations, logSafeError } from './http-operations.js';
import { MATCH_CLEANUP_INTERVAL_MS } from '../../constants/presence.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 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);
@@ -32,6 +34,15 @@ export async function createGameServer(options: ServerOptions): Promise<FastifyI
? { revision: current.revision } : { revision: current.revision, settings: current }; ? { 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');
let cleanupClosed = false, cleanupTask: Promise<void> | undefined;
const cleanup = () => {
if (cleanupClosed) return;
cleanupTask = gameSessions.cleanupDisconnectedMatches()
.catch(error => logSafeError(app.log, 'match_cleanup', error))
.finally(() => { if (!cleanupClosed) cleanupTimer = clock.setTimeout(cleanup, MATCH_CLEANUP_INTERVAL_MS); });
};
let cleanupTimer = clock.setTimeout(cleanup, MATCH_CLEANUP_INTERVAL_MS);
registerPresence(io, gameSessions);
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', () => logSafeError(app.log, 'ranked_background', undefined)); 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());
@@ -56,6 +67,7 @@ export async function createGameServer(options: ServerOptions): Promise<FastifyI
} }
registerAdmin(app, store, analytics, clock, { user: options.adminUser, password: options.adminPassword }, directory, settings); registerAdmin(app, store, analytics, clock, { user: options.adminUser, password: options.adminPassword }, directory, settings);
app.addHook('preClose', async () => { app.addHook('preClose', async () => {
cleanupClosed = true; clock.clearTimeout(cleanupTimer); await cleanupTask;
await ranked.close(); await ranked.close();
await analytics.close(); await analytics.close();
await new Promise<void>(done => io.close(() => done())); await new Promise<void>(done => io.close(() => done()));
+46 -2
View File
@@ -8,6 +8,7 @@ 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 { createGlobalSettingsStore } from './global-settings.js';
import { DEFAULT_GLOBAL_SETTINGS } from '../../constants/global-settings.js'; import { DEFAULT_GLOBAL_SETTINGS } from '../../constants/global-settings.js';
import { MATCH_PRESENCE_GRACE_MS } from '../../constants/presence.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); }
@@ -17,14 +18,17 @@ const hash = (value: string) => createHash('sha256').update(value).digest('hex')
interface SessionDocument { interface SessionDocument {
_id: string; tokenHash: string; playerId?: string; environment: string; createdAt: Date; updatedAt: Date; expiresAt: Date; _id: string; tokenHash: string; playerId?: string; environment: string; createdAt: Date; updatedAt: Date; expiresAt: Date;
stats: PlayerStats; lastAppliedSeq: number; stats: PlayerStats; lastAppliedSeq: number;
presenceUntil?: Date;
activeMatch?: ActiveMatch & { registerRequestId: string }; activeMatch?: ActiveMatch & { registerRequestId: string };
lastResult?: { matchId: string; seq: number; fingerprint: string; outcome: string; appliedAt: Date }; lastResult?: { matchId: string; seq: number; fingerprint: string; outcome: string; appliedAt: Date; abandoned?: boolean };
} }
export interface RegisterMatch extends Omit<ActiveMatch, 'seq'> { requestId: string; expectedPreviousSeq: number } export interface RegisterMatch extends Omit<ActiveMatch, 'seq'> { requestId: string; expectedPreviousSeq: number }
export async function createGameSessionStore(db: Db, now: () => number = Date.now, environment = 'production') { export async function createGameSessionStore(db: Db, now: () => number = Date.now, environment = 'production') {
const sessions = db.collection<SessionDocument>('game_sessions'); const sessions = db.collection<SessionDocument>('game_sessions');
const recoverUntil = now() + MATCH_PRESENCE_GRACE_MS;
await sessions.createIndex({ expiresAt: 1 }, { expireAfterSeconds: 0 }); await sessions.createIndex({ expiresAt: 1 }, { expireAfterSeconds: 0 });
await sessions.createIndex({ playerId: 1, updatedAt: -1 }); await sessions.createIndex({ playerId: 1, updatedAt: -1 });
await sessions.createIndex({ presenceUntil: 1 });
const authFilter = (auth: GameSessionAuth) => { const authFilter = (auth: GameSessionAuth) => {
if (!auth || !validId(auth.gameSessionId) || typeof auth.token !== 'string' || !/^[A-Za-z0-9_-]{43}$/.test(auth.token)) throw new ApiError(401, 'INVALID_SESSION'); if (!auth || !validId(auth.gameSessionId) || typeof auth.token !== 'string' || !/^[A-Za-z0-9_-]{43}$/.test(auth.token)) throw new ApiError(401, 'INVALID_SESSION');
return { _id: auth.gameSessionId, tokenHash: hash(auth.token), expiresAt: { $gt: new Date(now()) } }; return { _id: auth.gameSessionId, tokenHash: hash(auth.token), expiresAt: { $gt: new Date(now()) } };
@@ -61,6 +65,45 @@ export async function createGameSessionStore(db: Db, now: () => number = Date.no
return publicState(await read({ gameSessionId, token }), { gameSessionId, token }); return publicState(await read({ gameSessionId, token }), { gameSessionId, token });
}, },
async get(auth: GameSessionAuth) { return publicState(await read(auth), auth); }, async get(auth: GameSessionAuth) { return publicState(await read(auth), auth); },
async touchPresence(auth: GameSessionAuth) {
const doc = await sessions.findOneAndUpdate(authFilter(auth), {
$max: { presenceUntil: new Date(now() + MATCH_PRESENCE_GRACE_MS) },
}, { returnDocument: 'after' });
if (!doc) throw new ApiError(401, 'INVALID_SESSION');
return publicState(doc, auth);
},
async cleanupDisconnectedMatches() {
// Give existing tabs time to reconnect after a server restart or deployment.
if (now() < recoverUntil) return;
const at = new Date(now());
const expired = { $or: [
{ presenceUntil: { $lte: at } },
{ presenceUntil: { $exists: false }, updatedAt: { $lte: new Date(now() - MATCH_PRESENCE_GRACE_MS) } },
] };
for await (const candidate of sessions.find({ ...expired, activeMatch: { $exists: true } })) {
// Recheck the lease and match in the write: reconnects and result saves may have won the race.
await sessions.updateOne({ ...expired, _id: candidate._id, 'activeMatch.matchId': candidate.activeMatch!.matchId,
'activeMatch.seq': candidate.activeMatch!.seq }, [
{ $set: { lastAppliedSeq: '$activeMatch.seq', updatedAt: at,
lastResult: { matchId: '$activeMatch.matchId', seq: '$activeMatch.seq',
fingerprint: '', outcome: 'aborted', abandoned: true, appliedAt: at } } },
{ $unset: 'activeMatch' },
]);
}
// Rooms may outlive already-saved results. Discover them independently so failures can be retried.
const rooms = db.collection<any>('rooms');
for await (const room of rooms.find({ open: true })) {
const protectedMember = await sessions.findOne({ _id: { $in: room.members }, $or: [
{ activeMatch: { $exists: true } },
{ presenceUntil: { $gt: at } },
{ presenceUntil: { $exists: false }, updatedAt: { $gt: new Date(+at - MATCH_PRESENCE_GRACE_MS) } },
] });
if (protectedMember) continue;
await rooms.updateOne({ _id: room._id, members: room.members, currentMatchId: room.currentMatchId,
round: room.round, status: room.status, open: true },
{ $set: { open: false, status: 'closed' } });
}
},
async register(auth: GameSessionAuth, request: RegisterMatch): Promise<ActiveMatch> { async register(auth: GameSessionAuth, request: RegisterMatch): Promise<ActiveMatch> {
if (!request || !validId(request.requestId) || !validId(request.matchId) || !['bot', 'friend'].includes(request.mode) || !Number.isSafeInteger(request.expectedPreviousSeq) || request.expectedPreviousSeq < 0) throw new ApiError(400, 'INVALID_REQUEST'); if (!request || !validId(request.requestId) || !validId(request.matchId) || !['bot', 'friend'].includes(request.mode) || !Number.isSafeInteger(request.expectedPreviousSeq) || request.expectedPreviousSeq < 0) throw new ApiError(400, 'INVALID_REQUEST');
const before = await read(auth); const before = await read(auth);
@@ -86,7 +129,7 @@ export async function createGameSessionStore(db: Db, now: () => number = Date.no
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, settings, 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, presenceUntil: new Date(now() + MATCH_PRESENCE_GRACE_MS), updatedAt: new Date(now()), expiresAt: new Date(now() + GAME_SESSION_TTL_MS) },
}, { returnDocument: 'after' }); }, { returnDocument: 'after' });
if (doc) return active(doc)!; if (doc) return active(doc)!;
const current = await read(auth); const current = await read(auth);
@@ -116,6 +159,7 @@ export async function createGameSessionStore(db: Db, now: () => number = Date.no
const current = await read(auth); const current = await read(auth);
if (input.seq < current.lastAppliedSeq) return { status: 'stale', stats: current.stats }; if (input.seq < current.lastAppliedSeq) return { status: 'stale', stats: current.stats };
if (input.seq === current.lastAppliedSeq && current.lastResult?.matchId === input.matchId) { if (input.seq === current.lastAppliedSeq && current.lastResult?.matchId === input.matchId) {
if (current.lastResult.abandoned) return { status: 'stale', stats: current.stats };
if (current.lastResult.fingerprint !== fingerprint) throw new ApiError(409, 'RESULT_CONFLICT'); if (current.lastResult.fingerprint !== fingerprint) throw new ApiError(409, 'RESULT_CONFLICT');
return { status: 'duplicate', stats: current.stats }; return { status: 'duplicate', stats: current.stats };
} }
+2 -2
View File
@@ -16,7 +16,7 @@ export function createGlobalSettingsStore(db: Db) {
return { return {
async initialize() { async initialize() {
await collection.updateOne({ _id: 'global' }, { $setOnInsert: DEFAULT_GLOBAL_SETTINGS }, { upsert: true }); await collection.updateOne({ _id: 'global' }, { $setOnInsert: DEFAULT_GLOBAL_SETTINGS }, { upsert: true });
for (const key of ['matchingDelayMs', 'matchSuccessDelayMs', 'matchPreparationDelayMs'] as const) { for (const key of ['matchingDelayMs', 'matchSuccessDelayMs', 'matchPreparationDelayMs', 'rankedAcceptTimeoutMs'] as const) {
await collection.updateOne({ _id: 'global', [key]: { $exists: false } }, { $set: { [key]: DEFAULT_GLOBAL_SETTINGS[key] }, $inc: { revision: 1 } }); await collection.updateOne({ _id: 'global', [key]: { $exists: false } }, { $set: { [key]: DEFAULT_GLOBAL_SETTINGS[key] }, $inc: { revision: 1 } });
} }
}, },
@@ -24,7 +24,7 @@ export function createGlobalSettingsStore(db: Db) {
async update(input: unknown) { async update(input: unknown) {
if (!isGlobalSettings(input) || Object.keys(input).some(key => !Object.hasOwn(DEFAULT_GLOBAL_SETTINGS, key))) throw new ApiError(400, 'INVALID_SETTINGS'); 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 }, { 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 }, $set: { turnDurationMs: input.turnDurationMs, finishCutsceneSpeed: input.finishCutsceneSpeed, matchingDelayMs: input.matchingDelayMs, matchSuccessDelayMs: input.matchSuccessDelayMs, matchPreparationDelayMs: input.matchPreparationDelayMs, rankedAcceptTimeoutMs: input.rankedAcceptTimeoutMs }, $inc: { revision: 1 },
}, { returnDocument: 'after' }); }, { returnDocument: 'after' });
if (!doc) throw new ApiError(409, 'SETTINGS_REVISION_CONFLICT'); if (!doc) throw new ApiError(409, 'SETTINGS_REVISION_CONFLICT');
const { _id, ...settings } = doc; const { _id, ...settings } = doc;
+1 -1
View File
@@ -36,7 +36,7 @@ export function createHttpOptions(options: { environment?: 'production' | 'test'
}; };
} }
type ErrorOperation = 'http_request' | 'readiness' | 'startup' | 'shutdown' | 'ranked_background'; type ErrorOperation = 'http_request' | 'readiness' | 'startup' | 'shutdown' | 'ranked_background' | 'match_cleanup';
// Deliberately exclude error message, stack, cause, arbitrary name/code and request input. // 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) { export function logSafeError(logger: Pick<FastifyBaseLogger, 'error'>, operation: ErrorOperation, error: unknown, request?: FastifyRequest) {
const name = error instanceof Error ? error.name : undefined; const name = error instanceof Error ? error.name : undefined;
+36
View File
@@ -0,0 +1,36 @@
import type { Server } from 'socket.io';
import { ApiError, type GameSessionStore } from './game-session-store.js';
import { PRESENCE_AUTH_REFRESH_MS } from '../../constants/presence.js';
// The application is deployed as one process, as is the ranked match service.
export function registerPresence(io: Server, sessions: GameSessionStore) {
const namespace = io.of('/presence');
let count = 0;
function publish() {
const next = new Set([...namespace.sockets.values()].filter(socket => socket.connected).map(socket => socket.data.playerId)).size;
if (next !== count) { count = next; namespace.emit('presence:count', count); }
}
namespace.use((socket, next) => {
const { gameSessionId, token } = socket.handshake.auth;
void sessions.touchPresence({ gameSessionId, token }).then(session => {
socket.data.playerId = session.playerId ?? session.gameSessionId;
next();
}, error => next(new Error(error instanceof ApiError && error.statusCode === 401 ? 'INVALID_SESSION' : 'PRESENCE_UNAVAILABLE')));
});
namespace.on('connection', socket => {
const before = count; publish();
if (before === count) socket.emit('presence:count', count);
let checking = false;
const timer = setInterval(() => {
if (checking) return;
checking = true;
const { gameSessionId, token } = socket.handshake.auth;
void sessions.touchPresence({ gameSessionId, token }).catch(error => {
if (error instanceof ApiError && error.statusCode === 401) socket.disconnect(true);
else socket.conn.close(); // Transport closure permits Socket.IO to reconnect after a transient outage.
}).finally(() => { checking = false; });
}, PRESENCE_AUTH_REFRESH_MS);
timer.unref();
socket.on('disconnect', () => { clearInterval(timer); publish(); });
});
}
+16 -2
View File
@@ -14,21 +14,35 @@ export function registerRankedApi(app: FastifyInstance, sessions: GameSessionSto
gameRoute(app, 'GET', '/api/profile', async request => service.profile((await identity(request)).playerId)); gameRoute(app, 'GET', '/api/profile', async request => service.profile((await identity(request)).playerId));
gameRoute(app, 'POST', '/api/profile', async request => { gameRoute(app, 'POST', '/api/profile', async request => {
const { playerId } = await identity(request); const { playerId } = await identity(request);
return service.updateProfile(playerId, request.body as { appearance?: FighterAppearance }); return service.updateProfile(playerId, request.body as { displayName?: string; appearance?: FighterAppearance });
}); });
gameRoute(app, 'GET', '/api/ranked/leaderboard', async request => service.leaderboard((await identity(request)).playerId)); gameRoute(app, 'GET', '/api/ranked/leaderboard', async request => service.leaderboard((await identity(request)).playerId));
for (const [url, method] of [['status', 'GET'], ['queue', 'POST'], ['cancel', 'POST']] as const) { for (const [url, method] of [['status', 'GET'], ['queue', 'POST']] as const) {
gameRoute(app, method, `/api/ranked/${url}`, async request => { gameRoute(app, method, `/api/ranked/${url}`, async request => {
const { playerId, gameSessionId } = await identity(request); const { playerId, gameSessionId } = await identity(request);
return service[url === 'queue' ? 'join' : url](playerId, gameSessionId); return service[url === 'queue' ? 'join' : url](playerId, gameSessionId);
}); });
} }
gameRoute(app, 'POST', '/api/ranked/cancel', async request => {
const { playerId, gameSessionId } = await identity(request);
return service.cancel(playerId, gameSessionId, (request.body as { matchId?: string } | undefined)?.matchId);
});
gameRoute(app, 'POST', '/api/ranked/accept', async request => {
const { playerId, gameSessionId } = await identity(request);
return service.accept(playerId, gameSessionId, (request.body as { matchId: string } | undefined)?.matchId!);
});
gameRoute(app, 'POST', '/api/ranked/action', async request => { gameRoute(app, 'POST', '/api/ranked/action', async request => {
const { playerId, gameSessionId } = await identity(request); const { playerId, gameSessionId } = await identity(request);
const body = request.body as { matchId: string; turnId: number; action: ActionId } | undefined; const body = request.body as { matchId: string; turnId: number; action: ActionId } | undefined;
if (!body) throw new ApiError(400, 'INVALID_REQUEST'); if (!body) throw new ApiError(400, 'INVALID_REQUEST');
return service.action(playerId, gameSessionId, body.matchId, body.turnId, body.action); return service.action(playerId, gameSessionId, body.matchId, body.turnId, body.action);
}); });
gameRoute(app, 'POST', '/api/ranked/emote', async request => {
const { playerId, gameSessionId } = await identity(request);
const body = request.body as { matchId: string; id: unknown } | undefined;
if (!body) throw new ApiError(400, 'INVALID_REQUEST');
return service.emote(playerId, gameSessionId, body.matchId, body.id);
});
gameRoute(app, 'POST', '/api/ranked/leave', async request => { gameRoute(app, 'POST', '/api/ranked/leave', async request => {
const { playerId, gameSessionId } = await identity(request); const { playerId, gameSessionId } = await identity(request);
const body = request.body as { matchId: string } | undefined; const body = request.body as { matchId: string } | undefined;
+126 -43
View File
@@ -1,8 +1,7 @@
import { randomInt, randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import type { Db } from 'mongodb'; import type { Db } from 'mongodb';
import { COSMETIC_COLORS, EQUIPMENT_IDS, EYEWEAR_IDS, HAIR_STYLES, SKIN_TONES } from '../../constants/cosmetics.js'; import { COSMETIC_COLORS, EQUIPMENT_IDS, EYEWEAR_IDS, HAIR_STYLES, SKIN_TONES } from '../../constants/cosmetics.js';
import { import {
PROFILE_NAME_NOUNS, PROFILE_NAME_PREFIXES, PROFILE_NAME_SUFFIX_MAX, PROFILE_NAME_SUFFIX_MIN, PROFILE_NAME_VERSION,
RANKED_FINISHED_TTL_MS, RANKED_LEADERBOARD_LIMIT, RANKED_QUEUE_TTL_MS, RANKED_SAVE_RETRY_MS, RANKED_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, 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';
@@ -15,37 +14,35 @@ 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 { createGlobalSettingsStore } from './global-settings.js';
import { matchIntroductionDuration } from '../shared/match-introduction.js'; import { createEmotes } from '../shared/emotes.js';
import { profileNameError } from '../shared/profile-name.js';
interface ProfileDocument { interface ProfileDocument {
_id: string; displayName: string; nameVersion?: number; appearance: FighterAppearance; environment: string; createdAt: Date; updatedAt: Date; _id: string; displayName: string; nameTag?: number; appearance: FighterAppearance; environment: string; createdAt: Date; updatedAt: Date;
} }
interface TerminalMatch { interface TerminalMatch {
_id: string; playerIds: Pair<string>; appearances: Pair<FighterAppearance>; winnerIndex: 0 | 1 | null; _id: string; playerIds: Pair<string>; appearances: Pair<FighterAppearance>; winnerIndex: 0 | 1 | null;
reason: LocalEndReason; order: number; completedAt: Date; environment: string; reason: LocalEndReason; order: number; completedAt: Date; environment: string;
} }
interface QueueEntry { playerId: string; gameSessionId: string; expiresAt: number } interface QueueEntry { playerId: string; gameSessionId: string; expiresAt: number; previousMatchId?: string }
interface RankedMatch { interface RankedMatch {
confirmation?: { expiresAt: number; accepted: Pair<boolean>; sessions: Pair<string> };
emotes: ReturnType<typeof createEmotes>;
emoteVersion: number;
id: string; playerIds: Pair<string>; appearances: Pair<FighterAppearance>; engine: MatchEngine; id: string; playerIds: Pair<string>; appearances: Pair<FighterAppearance>; engine: MatchEngine;
actions: Pair<ActionId | null>; departed: Set<string>; timer?: ReturnType<Clock['setTimeout']>; actions: Pair<ActionId | null>; departed: Set<string>; timer?: ReturnType<Clock['setTimeout']>;
terminal?: TerminalMatch; saved: boolean; finishedExpiresAt?: Pair<number>; terminal?: TerminalMatch; saved: boolean; finishedExpiresAt?: Pair<number>;
} }
const emptyStats = (): ModeStats => ({ wins: 0, losses: 0, draws: 0, current: 0, best: 0 }); const emptyStats = (): ModeStats => ({ wins: 0, losses: 0, draws: 0, current: 0, best: 0 });
function generatedName(): string {
return `${PROFILE_NAME_PREFIXES[randomInt(PROFILE_NAME_PREFIXES.length)]}${PROFILE_NAME_NOUNS[randomInt(PROFILE_NAME_NOUNS.length)]}-${randomInt(PROFILE_NAME_SUFFIX_MIN, PROFILE_NAME_SUFFIX_MAX)}`;
}
// One service owns the queue and all match clocks in the established single-process server. // 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', onError?: (code: string) => void) { 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 counters = db.collection<{ _id: string; value: number }>('profile_counters');
await profiles.createIndex({ nameTag: 1 }, { unique: true, sparse: true });
const highestTag = (await profiles.find({ nameTag: { $exists: true } }).sort({ nameTag: -1 }).limit(1).next())?.nameTag ?? 0;
await counters.updateOne({ _id: 'nameTag' }, { $max: { value: highestTag } }, { upsert: true });
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.
// The version guard makes interrupted migrations/restarts preserve assigned names.
for await (const profile of profiles.find({ nameVersion: { $ne: PROFILE_NAME_VERSION } })) {
await profiles.updateOne({ _id: profile._id, nameVersion: { $ne: PROFILE_NAME_VERSION } }, { $set: {
displayName: generatedName(), nameVersion: PROFILE_NAME_VERSION, updatedAt: new Date(clock.now()),
} });
}
await results.createIndex({ playerIds: 1, order: 1 }); await results.createIndex({ playerIds: 1, order: 1 });
let resultOrder = (await results.find().sort({ order: -1 }).limit(1).next())?.order ?? 0; let resultOrder = (await results.find().sort({ order: -1 }).limit(1).next())?.order ?? 0;
const queue = new Map<string, QueueEntry>(); const queue = new Map<string, QueueEntry>();
@@ -57,6 +54,14 @@ export async function createRankedService(db: Db, clock: Clock = systemClock, en
selves: Map<string, LeaderboardEntry | undefined>; complete: boolean } | undefined; selves: Map<string, LeaderboardEntry | undefined>; complete: boolean } | undefined;
let leaderboardRead: Promise<LeaderboardEntry[]> | undefined; let leaderboardRead: Promise<LeaderboardEntry[]> | undefined;
let tail: Promise<unknown> = Promise.resolve(), closed = false; let tail: Promise<unknown> = Promise.resolve(), closed = false;
async function ensureNameTag(profile: ProfileDocument): Promise<number> {
if (profile.nameTag !== undefined) return profile.nameTag;
const counter = (await counters.findOneAndUpdate({ _id: 'nameTag' }, { $inc: { value: 1 } }, { returnDocument: 'after' }))!;
// Concurrent reads may reserve unused numbers, but only the first assignment wins.
const assigned = await profiles.findOneAndUpdate({ _id: profile._id, nameTag: { $exists: false } },
{ $set: { nameTag: counter.value } }, { returnDocument: 'after' });
return assigned?.nameTag ?? (await profiles.findOne({ _id: profile._id }))!.nameTag!;
}
function serial<T>(work: () => Promise<T>): Promise<T> { function serial<T>(work: () => Promise<T>): Promise<T> {
const next = tail.then(() => { const next = tail.then(() => {
if (closed) throw new ApiError(503, 'RANKED_CLOSED'); if (closed) throw new ApiError(503, 'RANKED_CLOSED');
@@ -125,9 +130,10 @@ export async function createRankedService(db: Db, clock: Clock = systemClock, en
for await (const result of results.find().sort({ order: 1, _id: 1 })) for (const id of result.playerIds) { 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 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, const entries = (await Promise.all((await profiles.find({ _id: { $in: [...totals.keys()] } }).toArray()).map(async profile => ({ playerId: profile._id,
displayName: profile.displayName, wins: totals.get(profile._id)!.wins, best: totals.get(profile._id)!.best, nameTag: await ensureNameTag(profile),
})).sort((a, b) => b.best - a.best || b.wins - a.wins || (a.playerId < b.playerId ? -1 : a.playerId > b.playerId ? 1 : 0)) displayName: profileNameError(profile.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 })); .map((entry, index) => ({ rank: index + 1, ...entry }));
// Return an overlapping snapshot without caching it; retrying here can starve // Return an overlapping snapshot without caching it; retrying here can starve
// under steady saves. The next request refreshes, independent of gameplay. // under steady saves. The next request refreshes, independent of gameplay.
@@ -141,12 +147,13 @@ export async function createRankedService(db: Db, clock: Clock = systemClock, en
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());
await profiles.updateOne({ _id: playerId }, { $setOnInsert: { await profiles.updateOne({ _id: playerId }, { $setOnInsert: {
displayName: generatedName(), nameVersion: PROFILE_NAME_VERSION, displayName: '',
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 nameTag = await ensureNameTag(doc);
const stats = await readStats(playerId); const stats = await readStats(playerId);
return { playerId, displayName: doc.displayName, appearance: normalizeAppearance(doc.appearance), stats, return { playerId, displayName: profileNameError(doc.displayName) ? '' : doc.displayName, nameTag, appearance: normalizeAppearance(doc.appearance), stats,
unlockedColors: availableColorIds(stats), unlockedEquipment: availableEquipmentIds(stats) }; unlockedColors: availableColorIds(stats), unlockedEquipment: availableEquipmentIds(stats) };
} }
async function persist(match: RankedMatch) { async function persist(match: RankedMatch) {
@@ -178,7 +185,7 @@ export async function createRankedService(db: Db, clock: Clock = systemClock, en
const state = match.engine.view(); const state = match.engine.view();
if (state.aborted) return; if (state.aborted) return;
if (state.phase === 'finished' && match.saved && match.departed.size === 2) return; if (state.phase === 'finished' && match.saved && match.departed.size === 2) return;
const at = state.phase === 'countdown' ? state.startsAt : state.phase === 'selecting' ? state.deadline const at = match.confirmation ? match.confirmation.expiresAt : state.phase === 'countdown' ? state.startsAt : state.phase === 'selecting' ? state.deadline
: state.phase === 'revealing' ? state.reveal!.revealEndsAt : match.saved : state.phase === 'revealing' ? state.reveal!.revealEndsAt : match.saved
? Math.min(...match.playerIds.map((id, slot) => match.departed.has(id) ? Infinity : match.finishedExpiresAt![slot])) ? Math.min(...match.playerIds.map((id, slot) => match.departed.has(id) ? Infinity : match.finishedExpiresAt![slot]))
: clock.now() + RANKED_SAVE_RETRY_MS; : clock.now() + RANKED_SAVE_RETRY_MS;
@@ -192,13 +199,20 @@ export async function createRankedService(db: Db, clock: Clock = systemClock, en
} }
async function advance(match: RankedMatch) { async function advance(match: RankedMatch) {
try { try {
if (!matches.has(match)) return;
if (match.confirmation) {
if (clock.now() >= match.confirmation.expiresAt) await cancelConfirmation(match);
return;
}
let state = match.engine.view(); let state = match.engine.view();
if (state.phase === 'countdown' && clock.now() >= state.startsAt) match.engine.beginTurn(state.startsAt); if (state.phase === 'countdown' && clock.now() >= state.startsAt) match.engine.beginTurn(state.startsAt);
if (state.phase === 'revealing' && clock.now() >= state.reveal!.revealEndsAt) { if (state.phase === 'revealing' && clock.now() >= state.reveal!.revealEndsAt) {
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' && (match.actions.every(action => action !== null) || clock.now() >= state.deadline)) match.engine.resolve(match.actions); while (state.phase === 'selecting' && (match.actions.every(action => action !== null) || clock.now() >= state.deadline)) {
match.engine.resolve(match.actions); state = match.engine.view();
}
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);
@@ -206,11 +220,15 @@ export async function createRankedService(db: Db, clock: Clock = systemClock, en
} finally { schedule(match); } } finally { schedule(match); }
} }
async function statusView(playerId: string): Promise<RankedStatus> { async function statusView(playerId: string): Promise<RankedStatus> {
const match = byPlayer.get(playerId); let match = byPlayer.get(playerId);
if (match) await advance(match); if (match) await advance(match);
match = byPlayer.get(playerId);
const profile = await readProfile(playerId); const profile = await readProfile(playerId);
if (!match) return { status: queue.has(playerId) ? 'queued' : 'idle', profile }; if (!match) return { status: queue.has(playerId) ? 'queued' : 'idle', profile };
const selfIndex = match.playerIds[0] === playerId ? 0 : 1; const selfIndex = match.playerIds[0] === playerId ? 0 : 1;
if (match.confirmation) return { status: 'confirming', profile, offer: {
matchId: match.id, expiresAt: match.confirmation.expiresAt, serverNow: clock.now(), accepted: match.confirmation.accepted[selfIndex],
} };
const state = match.engine.view(); const state = match.engine.view();
if (state.finished) { if (state.finished) {
state.finished.stats.friend = structuredClone(profile.stats); state.finished.stats.friend = structuredClone(profile.stats);
@@ -220,8 +238,9 @@ export async function createRankedService(db: Db, clock: Clock = systemClock, en
state.selfIndex = selfIndex; state.ownAction = match.actions[selfIndex]; state.selfIndex = selfIndex; state.ownAction = match.actions[selfIndex];
// Own submissions change this participant's version without revealing whether the // Own submissions change this participant's version without revealing whether the
// opponent has locked a choice. Engine transitions always move past either value. // opponent has locked a choice. Engine transitions always move past either value.
state.version = state.version * 2 + (state.ownAction === null ? 0 : 1); state.version = (state.version + match.emoteVersion) * 2 + (state.ownAction === null ? 0 : 1);
state.ranked = true; state.appearances = structuredClone(match.appearances); state.ranked = true; state.appearances = structuredClone(match.appearances);
state.emotes = match.emotes.view(clock.now());
state.opponentLeft = match.departed.has(match.playerIds[1 - selfIndex]); state.opponentLeft = match.departed.has(match.playerIds[1 - selfIndex]);
state.opponentPresent = !state.opponentLeft; state.opponentPresent = !state.opponentLeft;
return { status: 'matched', profile, match: state }; return { status: 'matched', profile, match: state };
@@ -232,17 +251,55 @@ export async function createRankedService(db: Db, clock: Clock = systemClock, en
if (!match || match.id !== matchId) throw new ApiError(409, 'NO_ACTIVE_MATCH'); if (!match || match.id !== matchId) throw new ApiError(409, 'NO_ACTIVE_MATCH');
return match; return match;
} }
async function pairQueued() {
expireQueue();
let candidate: QueueEntry | undefined;
for (const entry of queue.values()) {
try { await identity(entry.playerId, entry.gameSessionId); await freeForRanked(entry.playerId); }
catch (error) {
if (!(error instanceof ApiError)) throw error;
queue.delete(entry.playerId); continue;
}
if (!candidate) { candidate = entry; continue; }
const first = await readProfile(candidate.playerId), second = await readProfile(entry.playerId);
const settings = await createGlobalSettingsStore(db).get();
const id = randomUUID(), engine = createMatchEngine({ matchId: id, mode: 'friend', selfIndex: 0, clock, settings });
const match: RankedMatch = { id, engine, emotes: createEmotes(), emoteVersion: 0, playerIds: [candidate.playerId, entry.playerId],
appearances: [first.appearance, second.appearance], actions: [null, null], departed: new Set(), saved: false,
confirmation: { expiresAt: clock.now() + settings.rankedAcceptTimeoutMs, accepted: [false, false], sessions: [candidate.gameSessionId, entry.gameSessionId] } };
matches.add(match);
for (const playerId of match.playerIds) { queue.delete(playerId); byPlayer.set(playerId, match); }
schedule(match); candidate = undefined;
}
}
async function cancelConfirmation(match: RankedMatch, cancelledBy?: string) {
const confirmation = match.confirmation!;
if (match.timer !== undefined) clock.clearTimeout(match.timer);
matches.delete(match);
for (const slot of [0, 1] as const) {
const playerId = match.playerIds[slot];
byPlayer.delete(playerId);
if (confirmation.accepted[slot] && playerId !== cancelledBy) queue.set(playerId, {
playerId, gameSessionId: confirmation.sessions[slot], expiresAt: clock.now() + RANKED_QUEUE_TTL_MS, previousMatchId: match.id,
});
}
await pairQueued();
}
return { return {
async profile(playerId: string) { async profile(playerId: string) {
if (closed) throw new ApiError(503, 'RANKED_CLOSED'); if (closed) throw new ApiError(503, 'RANKED_CLOSED');
return readProfile(playerId); return readProfile(playerId);
}, },
updateProfile(playerId: string, input: { appearance?: FighterAppearance }): Promise<PlayerProfile> { updateProfile(playerId: string, input: { displayName?: string; appearance?: FighterAppearance }): Promise<PlayerProfile> {
return serial(async () => { return serial(async () => {
if (!input || typeof input !== 'object' || Array.isArray(input)) throw new ApiError(400, 'INVALID_PROFILE'); if (!input || typeof input !== 'object' || Array.isArray(input)) throw new ApiError(400, 'INVALID_PROFILE');
if (Object.hasOwn(input, 'displayName')) throw new ApiError(403, 'PROFILE_NAME_READONLY'); if (Object.hasOwn(input, 'displayName')) {
const error = profileNameError(input.displayName);
if (error) throw new ApiError(400, error);
}
const profile = await readProfile(playerId); const profile = await readProfile(playerId);
const update: Partial<ProfileDocument> = {}; const update: Partial<ProfileDocument> = {};
if (Object.hasOwn(input, 'displayName')) update.displayName = input.displayName!.normalize('NFKC').trim();
if (input.appearance !== undefined) { if (input.appearance !== undefined) {
const appearance = input.appearance; const appearance = input.appearance;
if (!appearance || typeof appearance !== 'object' || Array.isArray(appearance) if (!appearance || typeof appearance !== 'object' || Array.isArray(appearance)
@@ -263,6 +320,7 @@ export async function createRankedService(db: Db, clock: Clock = systemClock, en
update.appearance = next; update.appearance = next;
} }
if (Object.keys(update).length) await profiles.updateOne({ _id: playerId }, { $set: { ...update, updatedAt: new Date(clock.now()) } }); if (Object.keys(update).length) await profiles.updateOne({ _id: playerId }, { $set: { ...update, updatedAt: new Date(clock.now()) } });
if (update.displayName !== undefined) { resultRevision++; leaderboardCache = undefined; leaderboardRead = undefined; }
return readProfile(playerId); return readProfile(playerId);
}); });
}, },
@@ -280,7 +338,7 @@ export async function createRankedService(db: Db, clock: Clock = systemClock, en
const pending = leaderboardRead ??= scanLeaderboard(); const pending = leaderboardRead ??= scanLeaderboard();
try { try {
const entries = await pending, self = entries.find(entry => entry.playerId === playerId); const entries = await pending, self = entries.find(entry => entry.playerId === playerId);
if (leaderboardCache) { if (leaderboardCache && leaderboardRead === pending) {
leaderboardCache.selves.delete(playerId); leaderboardCache.selves.set(playerId, self); leaderboardCache.selves.delete(playerId); leaderboardCache.selves.set(playerId, self);
if (leaderboardCache.selves.size > RANKED_LEADERBOARD_CACHE_LIMIT) { if (leaderboardCache.selves.size > RANKED_LEADERBOARD_CACHE_LIMIT) {
leaderboardCache.selves.delete(leaderboardCache.selves.keys().next().value!); leaderboardCache.selves.delete(leaderboardCache.selves.keys().next().value!);
@@ -313,35 +371,58 @@ export async function createRankedService(db: Db, clock: Clock = systemClock, en
if (byPlayer.has(playerId)) return statusView(playerId); if (byPlayer.has(playerId)) return statusView(playerId);
await freeForRanked(playerId); await readProfile(playerId); await freeForRanked(playerId); await readProfile(playerId);
queue.set(playerId, { playerId, gameSessionId, expiresAt: clock.now() + RANKED_QUEUE_TTL_MS }); queue.set(playerId, { playerId, gameSessionId, expiresAt: clock.now() + RANKED_QUEUE_TTL_MS });
for (const candidate of queue.values()) { await pairQueued();
if (candidate.playerId === playerId) continue; return statusView(playerId);
try { await identity(candidate.playerId, candidate.gameSessionId); await freeForRanked(candidate.playerId); } });
catch (error) { },
if (!(error instanceof ApiError)) throw error; accept(playerId: string, gameSessionId: string, matchId: string): Promise<RankedStatus> {
queue.delete(candidate.playerId); continue; return serial(async () => {
} await identity(playerId, gameSessionId);
const first = await readProfile(candidate.playerId), second = await readProfile(playerId); if (!validId(matchId)) throw new ApiError(400, 'INVALID_REQUEST');
const id = randomUUID(), engine = createMatchEngine({ matchId: id, mode: 'friend', selfIndex: 0, clock, settings: await createGlobalSettingsStore(db).get() }); const match = byPlayer.get(playerId);
const match: RankedMatch = { id, engine, playerIds: [candidate.playerId, playerId], if (!match || match.id !== matchId) return statusView(playerId);
appearances: [first.appearance, second.appearance], actions: [null, null], departed: new Set(), saved: false }; await advance(match);
engine.start(clock.now() + matchIntroductionDuration(engine.view().settings, true)); matches.add(match); if (byPlayer.get(playerId) !== match || !match.confirmation) return statusView(playerId);
for (const id of match.playerIds) { queue.delete(id); byPlayer.set(id, match); } const slot = match.playerIds[0] === playerId ? 0 : 1;
schedule(match); break; match.confirmation.accepted[slot] = true;
match.confirmation.sessions[slot] = gameSessionId;
if (match.confirmation.accepted.every(Boolean)) {
match.confirmation = undefined;
match.engine.start(clock.now()); schedule(match);
} }
return statusView(playerId); return statusView(playerId);
}); });
}, },
cancel(playerId: string, gameSessionId: string): Promise<RankedStatus> { cancel(playerId: string, gameSessionId: string, matchId?: string): Promise<RankedStatus> {
return serial(async () => { return serial(async () => {
await identity(playerId, gameSessionId); expireQueue(); await identity(playerId, gameSessionId); expireQueue();
if (byPlayer.has(playerId)) throw new ApiError(409, 'MATCH_PENDING'); if (matchId !== undefined && !validId(matchId)) throw new ApiError(400, 'INVALID_REQUEST');
const match = byPlayer.get(playerId);
if (matchId && match?.id !== matchId && queue.get(playerId)?.previousMatchId !== matchId) return statusView(playerId);
if (match) {
if (match.confirmation) await cancelConfirmation(match, playerId);
return statusView(playerId);
}
queue.delete(playerId); return statusView(playerId); queue.delete(playerId); return statusView(playerId);
}); });
}, },
emote(playerId: string, gameSessionId: string, matchId: string, id: unknown): Promise<RankedStatus> {
return serial(async () => {
await identity(playerId, gameSessionId);
const match = memberMatch(playerId, matchId); await advance(match);
if (match.confirmation || byPlayer.get(playerId) !== match) throw new ApiError(409, 'NO_ACTIVE_MATCH');
if (match.engine.view().phase === 'finished' || match.departed.size) throw new ApiError(409, 'NO_ACTIVE_MATCH');
const slot = match.playerIds[0] === playerId ? 0 : 1;
if (!match.emotes.send(slot, id, clock.now())) throw new ApiError(400, 'INVALID_REQUEST');
match.emoteVersion++;
return statusView(playerId);
});
},
action(playerId: string, gameSessionId: string, matchId: string, turnId: number, action: ActionId): Promise<RankedStatus> { action(playerId: string, gameSessionId: string, matchId: string, turnId: number, action: ActionId): Promise<RankedStatus> {
return serial(async () => { return serial(async () => {
await identity(playerId, gameSessionId); await identity(playerId, gameSessionId);
const match = memberMatch(playerId, matchId); await advance(match); const match = memberMatch(playerId, matchId); await advance(match);
if (match.confirmation || byPlayer.get(playerId) !== match) throw new ApiError(409, 'NO_ACTIVE_MATCH');
const state = match.engine.view(), slot = match.playerIds[0] === playerId ? 0 : 1; const state = match.engine.view(), slot = match.playerIds[0] === playerId ? 0 : 1;
if (!Number.isSafeInteger(turnId) || state.phase !== 'selecting' || turnId !== state.turnId if (!Number.isSafeInteger(turnId) || state.phase !== 'selecting' || turnId !== state.turnId
|| clock.now() < state.startsAt || clock.now() >= state.deadline || !isLegalAction(action, state.energy[slot])) throw new ApiError(400, 'INVALID_ACTION'); || clock.now() < state.startsAt || clock.now() >= state.deadline || !isLegalAction(action, state.energy[slot])) throw new ApiError(400, 'INVALID_ACTION');
@@ -356,6 +437,8 @@ export async function createRankedService(db: Db, clock: Clock = systemClock, en
if (!validId(matchId)) throw new ApiError(400, 'INVALID_REQUEST'); if (!validId(matchId)) throw new ApiError(400, 'INVALID_REQUEST');
if (!byPlayer.has(playerId) && await results.findOne({ _id: matchId, playerIds: playerId })) return statusView(playerId); if (!byPlayer.has(playerId) && await results.findOne({ _id: matchId, playerIds: playerId })) return statusView(playerId);
const match = memberMatch(playerId, matchId); await advance(match); const match = memberMatch(playerId, matchId); await advance(match);
if (byPlayer.get(playerId) !== match) return statusView(playerId);
if (match.confirmation) { await cancelConfirmation(match, playerId); return statusView(playerId); }
if (match.engine.view().phase !== 'finished') { if (match.engine.view().phase !== 'finished') {
match.engine.finish(match.playerIds[0] === playerId ? 1 : 0, 'forfeit'); match.engine.finish(match.playerIds[0] === playerId ? 1 : 0, 'forfeit');
await advance(match); await advance(match);
+19
View File
@@ -0,0 +1,19 @@
import { EMOTE_COOLDOWN_MS, EMOTE_DURATION_MS, EMOTE_IDS, type EmoteId } from '../../constants/emotes.js';
import type { Pair } from './types.js';
export interface Emote { id: EmoteId; variant: 0 | 1; at: number; expiresAt: number }
export function createEmotes() {
const latest: Pair<Emote | null> = [null, null];
return {
send(slot: 0 | 1, id: unknown, now: number, variant: unknown = Math.random() < 0.5 ? 0 : 1): boolean {
if (variant !== 0 && variant !== 1) return false;
if (!EMOTE_IDS.includes(id as EmoteId) || !Number.isFinite(now)) return false;
if (latest[slot] && now - latest[slot]!.at < EMOTE_COOLDOWN_MS) return false;
latest[slot] = { id: id as EmoteId, variant, at: now, expiresAt: now + EMOTE_DURATION_MS };
return true;
},
view(now: number): Pair<Emote | null> {
return latest.map(value => value && now < value.expiresAt ? { ...value } : null) as Pair<Emote | null>;
},
};
}
+2 -2
View File
@@ -1,11 +1,11 @@
import { GLOBAL_SETTINGS_LIMITS } from '../../constants/global-settings.js'; 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 interface GlobalSettings { revision: number; turnDurationMs: number; finishCutsceneSpeed: number; matchingDelayMs: number; matchSuccessDelayMs: number; matchPreparationDelayMs: number; rankedAcceptTimeoutMs: number }
export function isGlobalSettings(value: unknown): value is GlobalSettings { export function isGlobalSettings(value: unknown): value is GlobalSettings {
if (!value || typeof value !== 'object') return false; if (!value || typeof value !== 'object') return false;
const s = value as GlobalSettings, limits = GLOBAL_SETTINGS_LIMITS; const s = value as GlobalSettings, limits = GLOBAL_SETTINGS_LIMITS;
return Number.isSafeInteger(s.revision) && s.revision >= 1 return Number.isSafeInteger(s.revision) && s.revision >= 1
&& Number.isSafeInteger(s.turnDurationMs) && s.turnDurationMs >= limits.turnDurationMs.min && s.turnDurationMs <= limits.turnDurationMs.max && 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 && 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); && (['matchingDelayMs', 'matchSuccessDelayMs', 'matchPreparationDelayMs', 'rankedAcceptTimeoutMs'] as const).every(key => Number.isSafeInteger(s[key]) && s[key] >= limits[key].min && s[key] <= limits[key].max);
} }
+23 -5
View File
@@ -1,4 +1,4 @@
import { MATCH_COUNTDOWN_MS, REVEAL_DURATION_MS } from '../../constants/gameplay.js'; import { MATCH_COUNTDOWN_MS, REVEAL_DURATION_MS, TIMEOUT_CHANCES } from '../../constants/gameplay.js';
import { DEFAULT_GLOBAL_SETTINGS } from '../../constants/global-settings.js'; import { DEFAULT_GLOBAL_SETTINGS } from '../../constants/global-settings.js';
import { matchPreparationDuration } from './match-introduction.js'; import { matchPreparationDuration } from './match-introduction.js';
import { chooseBotAction } from './bot.js'; import { chooseBotAction } from './bot.js';
@@ -10,7 +10,7 @@ export function createMatchEngine(config: MatchConfig) {
let bot: ActionId | null = null; let bot: ActionId | null = null;
let state: LocalMatchState = { let state: LocalMatchState = {
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, timeouts: [0, 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, settings: { ...(config.settings ?? DEFAULT_GLOBAL_SETTINGS) }, saveStatus: 'pending', aborted: false, settings: { ...(config.settings ?? DEFAULT_GLOBAL_SETTINGS) },
}; };
@@ -20,7 +20,25 @@ export function createMatchEngine(config: MatchConfig) {
state.finished = { matchId: config.matchId, winnerIndex, reason, stats: emptyPlayerStats(), state.finished = { matchId: config.matchId, winnerIndex, reason, stats: emptyPlayerStats(),
revealEndsAt: state.reveal?.revealEndsAt ?? config.clock.now(), saved: false }; revealEndsAt: state.reveal?.revealEndsAt ?? config.clock.now(), saved: false };
} }
function finishTimeout(): boolean {
const exhausted = (state.timeouts ?? [0, 0]).map(count => count >= TIMEOUT_CHANCES);
if (!exhausted.some(Boolean)) return false;
const winner = exhausted[0] && exhausted[1] ? null : exhausted[0] ? 1 : 0;
finish(winner, winner === null ? 'double_timeout' : 'timeout');
return true;
}
function expireSelection(missing: Pair<boolean>, deadline = state.deadline): boolean {
if (state.phase !== 'selecting' || deadline !== state.deadline || config.clock.now() < deadline || !missing.some(Boolean)) return false;
state.timeouts ??= [0, 0];
for (const slot of [0, 1] as const) if (missing[slot]) state.timeouts[slot]++;
state.version++;
if (finishTimeout()) return false;
state.startsAt = deadline;
state.deadline = deadline + (state.settings ?? DEFAULT_GLOBAL_SETTINGS).turnDurationMs;
return true;
}
return { return {
expireSelection,
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 + (config.mode === 'friend' ? matchPreparationDuration(state.settings) : MATCH_COUNTDOWN_MS); state.version++; state.startsAt = startsAt + (config.mode === 'friend' ? matchPreparationDuration(state.settings) : MATCH_COUNTDOWN_MS); state.version++;
@@ -45,9 +63,8 @@ export function createMatchEngine(config: MatchConfig) {
if (config.clock.now() < state.startsAt || (actions.includes(null) && 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; expireSelection(actions.map(action => action === null) as Pair<boolean>);
finish(winner, winner === null ? 'double_timeout' : 'timeout'); return { energy: [...state.energy], winnerIndex: state.finished?.winnerIndex ?? null, hitIndex: null };
return { energy: [...state.energy], winnerIndex: winner, hitIndex: null };
} }
const result = resolveTurn(state.energy, actions as Pair<ActionId>), now = config.clock.now(); const result = resolveTurn(state.energy, actions as Pair<ActionId>), now = config.clock.now();
state.reveal = { matchId: config.matchId, turnId: state.turnId, actions: [...actions] as Pair<ActionId>, state.reveal = { matchId: config.matchId, turnId: state.turnId, actions: [...actions] as Pair<ActionId>,
@@ -72,6 +89,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;
if (finishTimeout()) return;
state.startsAt = startsAt; state.deadline = startsAt + (state.settings ?? DEFAULT_GLOBAL_SETTINGS).turnDurationMs; 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); },
+7 -1
View File
@@ -1,6 +1,8 @@
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'; import type { GlobalSettings } from './global-settings.js';
import type { Emote } from './emotes.js';
import type { EmoteId } from '../../constants/emotes.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';
@@ -23,6 +25,8 @@ 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'> {
timeouts?: Pair<number>;
emotes?: Pair<Emote | null>;
settings?: GlobalSettings; settings?: GlobalSettings;
clockNow: number; aborted: boolean; clockNow: number; aborted: boolean;
appearances?: Pair<FighterAppearance>; appearances?: Pair<FighterAppearance>;
@@ -38,9 +42,11 @@ export interface PeerHeader {
protocolVersion: number; rulesVersion: string; matchId: string; connectionEpoch: number; turnId: number; protocolVersion: number; rulesVersion: string; matchId: string; connectionEpoch: number; turnId: number;
} }
export type PeerMessage = PeerHeader & ( export type PeerMessage = PeerHeader & (
| { type: 'emote'; id: EmoteId; variant: 0 | 1 }
| { type: 'ready'; slot: 0 | 1 } | { type: 'ready'; slot: 0 | 1 }
| { type: 'clock:ping' | 'ping'; sentAt: number } | { type: 'clock:ping' | 'ping'; sentAt: number }
| { type: 'clock:pong' | 'pong'; sentAt: number; remoteAt: number } | { type: 'clock:pong' | 'pong'; sentAt: number; remoteAt: number }
| { type: 'turn:timeout'; deadline: number; missing: boolean; slot: 0 | 1 }
| { type: 'turn:start'; startsAt: number; resume?: boolean } | { type: 'turn:start'; startsAt: number; resume?: boolean }
| { type: 'turn:commit'; digest: string; slot: 0 | 1 } | { type: 'turn:commit'; digest: string; slot: 0 | 1 }
| { type: 'turn:reveal'; action: ActionId | null; nonce: string; slot: 0 | 1 } | { type: 'turn:reveal'; action: ActionId | null; nonce: string; slot: 0 | 1 }
@@ -50,7 +56,7 @@ export type PeerMessage = PeerHeader & (
| { type: 'resume' | 'resume:ack'; checkpoint: PublicCheckpoint } | { type: 'resume' | 'resume:ack'; checkpoint: PublicCheckpoint }
); );
export interface PublicCheckpoint { export interface PublicCheckpoint {
turnId: number; energy: Pair<number>; finished: boolean; winnerIndex: 0 | 1 | null; reason?: LocalEndReason; turnId: number; energy: Pair<number>; timeouts?: Pair<number>; finished: boolean; winnerIndex: 0 | 1 | null; reason?: LocalEndReason;
} }
export function emptyPlayerStats(): PlayerStats { export function emptyPlayerStats(): PlayerStats {
const mode = () => ({ wins: 0, losses: 0, draws: 0, current: 0, best: 0 }); const mode = () => ({ wins: 0, losses: 0, draws: 0, current: 0, best: 0 });
+18
View File
@@ -0,0 +1,18 @@
import { PROFILE_NAME_BLOCKED_WORDS, PROFILE_NAME_MAX_LENGTH, PROFILE_NAME_MIN_LENGTH, PROFILE_NAME_TAG_DIGITS } from '../../constants/profile-name.js';
export function formatProfileName(profile: { displayName: string; nameTag: number }): string {
return `${profile.displayName}#${String(profile.nameTag).padStart(PROFILE_NAME_TAG_DIGITS, '0')}`;
}
// Compare decomposed Hangul too, so compatibility/combining jamo and separators cannot hide listed words.
const comparisonName = (name: string) => name.normalize('NFKD').toLowerCase().replace(/[^\p{L}]/gu, '');
const blockedWords = PROFILE_NAME_BLOCKED_WORDS.map(comparisonName);
export function profileNameError(value: unknown): 'INVALID_PROFILE_NAME' | 'PROFILE_NAME_PROFANITY' | undefined {
if (typeof value !== 'string') return 'INVALID_PROFILE_NAME';
const name = value.normalize('NFKC').trim();
const comparison = comparisonName(name);
if (blockedWords.some(word => comparison.includes(word))) return 'PROFILE_NAME_PROFANITY';
if (name.length < PROFILE_NAME_MIN_LENGTH || name.length > PROFILE_NAME_MAX_LENGTH
|| !/^[가-힣ㄱ-ㅎㅏ-ㅣa-zA-Z0-9 _-]+$/.test(name)) return 'INVALID_PROFILE_NAME';
}
+4 -1
View File
@@ -6,20 +6,23 @@ import type { EquipmentId } from '../../constants/cosmetics.js';
export interface PlayerProfile { export interface PlayerProfile {
playerId: string; playerId: string;
displayName: string; displayName: string;
nameTag: number;
appearance: FighterAppearance; appearance: FighterAppearance;
stats: ModeStats; stats: ModeStats;
unlockedColors: string[]; unlockedColors: string[];
unlockedEquipment: EquipmentId[]; unlockedEquipment: EquipmentId[];
} }
export interface RankedStatus { export interface RankedStatus {
status: 'idle' | 'queued' | 'matched'; status: 'idle' | 'queued' | 'confirming' | 'matched';
profile: PlayerProfile; profile: PlayerProfile;
offer?: { matchId: string; expiresAt: number; serverNow: number; accepted: boolean };
match?: LocalMatchState; match?: LocalMatchState;
} }
export interface LeaderboardEntry { export interface LeaderboardEntry {
rank: number; rank: number;
playerId: string; playerId: string;
displayName: string; displayName: string;
nameTag: number;
wins: number; wins: number;
best: number; best: number;
} }
+36
View File
@@ -0,0 +1,36 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import type { AnalyticsStore } from '../src/server/analytics-store.js';
import { Analytics } from '../src/server/analytics.js';
import { ManualClock } from './helpers.js';
test('ten-second observations preserve a brief peak in one five-minute write and flush the final partial interval', async () => {
const clock = new ManualClock();
const writes: Parameters<AnalyticsStore['sample']>[] = [];
const store = {
sample: async (...args: Parameters<AnalyticsStore['sample']>) => { writes.push(args); },
cleanup: async () => {},
} as unknown as AnalyticsStore;
const analytics = new Analytics(store, clock);
try {
analytics.states.set('visitor', { visible: true, lastAt: clock.now(), metadata: { device: 'pc', browser: 'Other', source: 'direct_or_unknown', invitation: false } });
await clock.advance(290000);
assert.equal(writes.length, 0);
assert.equal(analytics.live().active, 0);
await clock.advance(10000);
assert.equal(writes.length, 1);
assert.equal(writes[0][1].active, 1);
assert.equal(writes[0][4], 30);
await clock.advance(300000);
assert.equal(writes.length, 2);
assert.equal(writes[1][1].active, 0);
assert.equal(writes[1][4], 30);
await clock.advance(20000);
assert.equal(writes.length, 2);
await analytics.close();
assert.equal(writes.length, 3);
assert.equal(writes[2][4], 2);
await analytics.close();
assert.equal(writes.length, 3);
} finally { await analytics.close(); }
});
+28 -1
View File
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { MongoClient } from 'mongodb'; import { MongoClient } from 'mongodb';
import { createAnalyticsStore } from '../src/server/analytics-store.js'; import { createAnalyticsStore } from '../src/server/analytics-store.js';
import { kstDay, ratio, retention } from '../src/server/analytics-report.js'; import { getAnalyticsReport, kstDay, ratio, retention } from '../src/server/analytics-report.js';
import { createStatsStore } from '../src/server/stats-store.js'; import { createStatsStore } from '../src/server/stats-store.js';
import { RETENTION_MS, VISIT_SESSION_MS } from '../constants/analytics.js'; import { RETENTION_MS, VISIT_SESSION_MS } from '../constants/analytics.js';
@@ -19,6 +19,33 @@ test('KST boundaries, zero denominators, calendar cohorts', () => {
}); });
test('presence retention shortens legacy expiry and reports count raw observations alongside aggregates', { skip: !process.env.TEST_MONGODB_URI }, async () => {
const stats = await createStatsStore({ mongoUri: process.env.TEST_MONGODB_URI!, databaseName: `dragon_ball_test_samples_${randomUUID().replaceAll('-', '')}` });
try {
const now = new Date(), at = new Date(+now - 60000), old = new Date(+now - 8 * 86400000);
const samples = stats.db.collection('analytics_presence_samples');
await samples.insertMany([
{ occurredAt: at, active: 2, environment: 'production', source: 'activity_reports', expiresAt: new Date(+at + RETENTION_MS) },
{ occurredAt: old, active: 99, environment: 'production', source: 'activity_reports', expiresAt: new Date(+old + RETENTION_MS) },
]);
const store = await createAnalyticsStore(stats.db);
assert.equal(+(await samples.findOne({ occurredAt: at }))!.expiresAt, +at + 604800000);
await store.sample(at, { active: 4, connected: null, waitingRooms: null, playingPeople: null, matches: null, source: 'activity_reports' }, 'production',
{ restartedAt: new Date(+at - 300000), failures: 2, dropped: 1, rateLimited: 3 }, 30);
const aggregate = await samples.findOne({ sampleCount: 30 });
assert.equal(+aggregate!.expiresAt, +at + 604800000);
const guest = await stats.createGuest();
await store.insert({ _id: 'old-visit', playerId: guest.playerId, occurredAt: old, eventType: 'page_view', environment: 'production', schemaVersion: 1, appVersion: '0.1.0' });
const report = await getAnalyticsReport(stats.db, { start: new Date(+now - 30 * 86400000), end: now }, now);
assert.deepEqual(report.sampledPeak, { count: 31, active: 4 });
assert.deepEqual(report.historicalCollection, { failures: 2, dropped: 1, rateLimited: 3 });
assert.equal(report.overview.mau.visitors, 1);
assert.equal(+(await stats.db.collection('analytics_events').findOne({ _id: 'old-visit' as any }))!.expiresAt, +old + RETENTION_MS);
await createAnalyticsStore(stats.db);
assert.equal(+(await samples.findOne({ sampleCount: 30 }))!.expiresAt, +at + 604800000);
} finally { await stats.db.dropDatabase(); await stats.close(); }
});
test('atomic visit session rotation, duplicate collection and safe metadata on actual MongoDB', { skip: !process.env.TEST_MONGODB_URI }, async () => { test('atomic visit session rotation, duplicate collection and safe metadata on actual MongoDB', { skip: !process.env.TEST_MONGODB_URI }, async () => {
const databaseName = `dragon_ball_test_analytics_${randomUUID().replaceAll('-', '')}`; const databaseName = `dragon_ball_test_analytics_${randomUUID().replaceAll('-', '')}`;
const stats = await createStatsStore({ mongoUri: process.env.TEST_MONGODB_URI!, databaseName }); const stats = await createStatsStore({ mongoUri: process.env.TEST_MONGODB_URI!, databaseName });
+4
View File
@@ -162,12 +162,16 @@ function viewHarness(t: import('node:test').TestContext) {
const actors = [new Element(), new Element()]; const actors = [new Element(), new Element()];
const container = new Element(); const container = new Element();
const previous = Object.getOwnPropertyDescriptor(globalThis, 'window'); const previous = Object.getOwnPropertyDescriptor(globalThis, 'window');
const previousDocument = Object.getOwnPropertyDescriptor(globalThis, 'document');
Object.defineProperty(globalThis, 'document', { configurable: true, value: { addEventListener() {}, removeEventListener() {} } });
Object.defineProperty(globalThis, 'window', { configurable: true, value: { Object.defineProperty(globalThis, 'window', { configurable: true, value: {
matchMedia: () => ({ matches: false, addEventListener() {}, removeEventListener() {} }), matchMedia: () => ({ matches: false, addEventListener() {}, removeEventListener() {} }),
} }); } });
const view = createMatchView(container as unknown as HTMLElement, async () => true, () => {}, () => {}); const view = createMatchView(container as unknown as HTMLElement, async () => true, () => {}, () => {});
t.after(() => { t.after(() => {
view.destroy(); view.destroy();
if (previousDocument) Object.defineProperty(globalThis, 'document', previousDocument);
else Reflect.deleteProperty(globalThis, 'document');
if (previous) Object.defineProperty(globalThis, 'window', previous); if (previous) Object.defineProperty(globalThis, 'window', previous);
else Reflect.deleteProperty(globalThis, 'window'); else Reflect.deleteProperty(globalThis, 'window');
}); });
+27
View File
@@ -0,0 +1,27 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createEmotes } from '../src/shared/emotes.js';
import { EMOTE_COOLDOWN_MS, EMOTE_DURATION_MS } from '../constants/emotes.js';
test('emotes validate fixed choices, rate limit each player and expire without changing battle state', () => {
const emotes = createEmotes();
assert.equal(emotes.send(0, '<script>', 1000), false);
assert.equal(emotes.send(0, 'greet', 1000), true);
assert.equal(emotes.send(0, 'mistake', 1001), false);
assert.equal(emotes.send(1, 'thanks', 1001), true);
assert.equal(emotes.view(1001)[0]?.id, 'greet');
const snapshot = emotes.view(1001);
snapshot[0]!.id = 'threaten';
assert.equal(emotes.view(1001)[0]?.id, 'greet');
assert.equal(emotes.view(1000 + EMOTE_DURATION_MS)[0], null);
assert.equal(emotes.send(0, 'mistake', 1000 + EMOTE_COOLDOWN_MS), true);
});
test('emote variants are fixed when sent and invalid variants are rejected', () => {
const emotes = createEmotes();
assert.equal(emotes.send(0, 'greet', 0, 2), false);
assert.equal(emotes.send(0, 'greet', 0, 0), true);
assert.equal(emotes.send(1, 'greet', 0, 1), true);
assert.equal(emotes.view(1)[0]?.variant, 0);
assert.equal(emotes.view(2)[1]?.variant, 1);
});
+43 -1
View File
@@ -252,7 +252,7 @@ function domHarness(t: import('node:test').TestContext, reducedMotion = true) {
} }
const actors = [new Element(), new Element()], appended: Element[] = []; const actors = [new Element(), new Element()], appended: Element[] = [];
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(), addEventListener() {}, removeEventListener() {} };
const globals = { document: doc, HTMLElement: Element, window: { const globals = { document: doc, HTMLElement: Element, window: {
matchMedia: () => ({ matches: reducedMotion, addEventListener() {}, removeEventListener() {} }), matchMedia: () => ({ matches: reducedMotion, addEventListener() {}, removeEventListener() {} }),
} }; } };
@@ -341,6 +341,47 @@ test('global cutscene speed scales stages and CSS timing while respecting result
} }
}); });
test('result sound waits for all three cutscene stages', t => {
const { container, state } = domHarness(t, false);
const started = state.clockNow, cues: string[] = [];
t.mock.timers.enable({ apis: ['Date', 'setTimeout', 'setInterval'], now: started });
t.mock.method(performance, 'now', () => Date.now() - started);
t.mock.method(sound, 'play', (id: string) => { if (!cues.includes(id)) cues.push(id); });
state.matchId = 'audio-stage-order'; state.phase = 'finished';
state.reveal = { matchId: state.matchId, turnId: 1, actions: ['blast', 'charge'], energyBefore: [1, 1], energy: [0, 1], hitIndex: 1, winnerIndex: 0,
revealedAt: started, revealEndsAt: started + 1200 };
state.finished = { matchId: state.matchId, winnerIndex: 0, reason: 'hit', stats: emptyPlayerStats(), revealEndsAt: started + 1200, saved: true };
const view = createMatchView(container as unknown as HTMLElement, async () => true, () => {}, () => {});
try {
view.update(state);
t.mock.timers.tick(1100); t.mock.timers.tick(100);
assert.deepEqual(cues, ['cutscene1', 'cutscene2']);
t.mock.timers.tick(1200);
assert.deepEqual(cues, ['cutscene1', 'cutscene2', 'cutscene3']);
t.mock.timers.tick(1200);
assert.deepEqual(cues, ['cutscene1', 'cutscene2', 'cutscene3', 'win']);
} finally { view.destroy(); }
});
test('a delayed finishing snapshot plays the remaining cutscene before result audio', t => {
const { container, state } = domHarness(t, false);
const started = state.clockNow, cues: string[] = [];
t.mock.timers.enable({ apis: ['Date', 'setTimeout', 'setInterval'], now: started });
t.mock.method(performance, 'now', () => Date.now() - started);
t.mock.method(sound, 'play', (id: string) => { if (!cues.includes(id)) cues.push(id); });
state.matchId = 'audio-delayed-snapshot'; state.phase = 'finished';
state.reveal = { matchId: state.matchId, turnId: 1, actions: ['blast', 'charge'], energyBefore: [1, 1], energy: [0, 1], hitIndex: 1, winnerIndex: 0,
revealedAt: started - 1500, revealEndsAt: started - 300 };
state.finished = { matchId: state.matchId, winnerIndex: 0, reason: 'hit', stats: emptyPlayerStats(), revealEndsAt: started - 300, saved: true };
const view = createMatchView(container as unknown as HTMLElement, async () => true, () => {}, () => {});
try {
view.update(state);
assert.deepEqual(cues, ['cutscene2']);
t.mock.timers.tick(900); t.mock.timers.tick(1200);
assert.deepEqual(cues, ['cutscene2', 'cutscene3', 'win']);
} finally { view.destroy(); }
});
for (const reducedMotion of [false, true]) test(`cutscene timer automatically opens results once (reduced motion: ${reducedMotion})`, t => { for (const reducedMotion of [false, true]) test(`cutscene timer automatically opens results once (reduced motion: ${reducedMotion})`, t => {
const { state } = domHarness(t, reducedMotion); const { state } = domHarness(t, reducedMotion);
t.mock.timers.enable({ apis: ['setTimeout'] }); t.mock.timers.enable({ apis: ['setTimeout'] });
@@ -404,3 +445,4 @@ test('a dismissed finisher stays closed when the opponent disconnects after the
assert.equal(dismissals, 1); assert.equal(appended.length, 1); assert.equal(dismissals, 1); assert.equal(appended.length, 1);
} finally { cutscene.destroy(); } } finally { cutscene.destroy(); }
}); });
import { sound } from '../src/client/sound.js';
+109
View File
@@ -2,9 +2,118 @@ import { test } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { randomBytes, randomUUID } from 'node:crypto'; import { randomBytes, randomUUID } from 'node:crypto';
import { MongoClient } from 'mongodb'; import { MongoClient } from 'mongodb';
import type { Db } from 'mongodb';
import { createGameSessionStore } from '../src/server/game-session-store.js'; import { createGameSessionStore } from '../src/server/game-session-store.js';
import { createRoomStore } from '../src/server/rooms.js';
const uri = process.env.TEST_MONGODB_URI; const uri = process.env.TEST_MONGODB_URI;
test('orphan friend cleanup releases its room without discarding the connected opponent result', { skip: !uri }, async () => {
const client = await new MongoClient(uri!).connect();
const db = client.db(`dragon_ball_test_orphan_room_${randomUUID().replaceAll('-', '')}`);
try {
let now = Date.now();
const store = await createGameSessionStore(db, () => now), rooms = await createRoomStore(db, () => now);
const a = await store.create(randomBytes(32).toString('base64url'));
const b = await store.create(randomBytes(32).toString('base64url'));
const waiting = await rooms.create(a.gameSessionId, 'room');
const room = await rooms.join(b.gameSessionId, waiting.code);
const request = { requestId: 'match', matchId: room.currentMatchId, roomId: room.id, mode: 'friend' as const, expectedPreviousSeq: 0 };
await store.register(a, request);
const match = await store.register(b, request);
now += 90000; await store.touchPresence(b);
now += 31000; await store.cleanupDisconnectedMatches();
assert.equal((await rooms.get(room.id, b.gameSessionId)).status, 'paired', 'a connected participant keeps the room');
assert.equal((await store.get(a)).activeMatch, undefined);
assert.equal((await store.get(b)).activeMatch?.matchId, match.matchId);
assert.equal((await store.record(b, { ...match, outcome: 'win', reason: 'disconnect' })).status, 'applied');
assert.deepEqual((await store.get(a)).stats, a.stats);
now += 90000; await store.cleanupDisconnectedMatches();
assert.equal((await rooms.get(room.id, b.gameSessionId)).status, 'closed');
} finally { await db.dropDatabase(); await client.close(); }
});
test('closed browsers release waiting rooms and rooms with both results already saved', { skip: !uri }, async () => {
const client = await new MongoClient(uri!).connect();
const db = client.db(`dragon_ball_test_saved_room_${randomUUID().replaceAll('-', '')}`);
try {
let now = Date.now();
const store = await createGameSessionStore(db, () => now), rooms = await createRoomStore(db, () => now);
const a = await store.create(randomBytes(32).toString('base64url'));
const b = await store.create(randomBytes(32).toString('base64url'));
const c = await store.create(randomBytes(32).toString('base64url'));
const waiting = await rooms.create(a.gameSessionId, 'paired-room');
const room = await rooms.join(b.gameSessionId, waiting.code);
const idle = await rooms.create(c.gameSessionId, 'waiting-room');
for (const session of [a, b]) {
const match = await store.register(session, { requestId: 'match', matchId: room.currentMatchId, roomId: room.id, mode: 'friend', expectedPreviousSeq: 0 });
await store.record(session, { ...match, outcome: 'draw', reason: 'double_timeout' });
}
now += 120001; await store.cleanupDisconnectedMatches();
assert.equal((await rooms.get(room.id, a.gameSessionId)).status, 'closed');
assert.equal((await rooms.get(idle.id, c.gameSessionId)).status, 'closed');
assert.equal((await store.get(a)).stats.friend.draws, 1);
} finally { await db.dropDatabase(); await client.close(); }
});
test('restart grace and a reconnect during cleanup protect the current match', { skip: !uri }, async () => {
const client = await new MongoClient(uri!).connect();
const db = client.db(`dragon_ball_test_reconnect_${randomUUID().replaceAll('-', '')}`);
try {
let now = Date.now();
const store = await createGameSessionStore(db, () => now);
const session = await store.create(randomBytes(32).toString('base64url'));
const match = await store.register(session, { requestId: 'request', matchId: 'match', mode: 'bot', expectedPreviousSeq: 0 });
// Legacy registrations from before lease tracking are protected on server startup too.
await db.collection('game_sessions').updateOne({ _id: session.gameSessionId as any }, { $unset: { presenceUntil: '' } });
now += 300000;
let reconnect = false;
const collection = db.collection('game_sessions');
const wrapped = new Proxy(db, { get(target, key) {
if (key === 'collection') return (name: string) => name !== 'game_sessions' ? target.collection(name) : new Proxy(collection, { get(c, method) {
if (method === 'updateOne') return async (...args: Parameters<typeof collection.updateOne>) => {
if (reconnect) { reconnect = false; await store.touchPresence(session); }
return c.updateOne(...args);
};
const value = Reflect.get(c, method); return typeof value === 'function' ? value.bind(c) : value;
} });
const value = Reflect.get(target, key); return typeof value === 'function' ? value.bind(target) : value;
} }) as Db;
const restarted = await createGameSessionStore(wrapped, () => now);
await restarted.cleanupDisconnectedMatches();
assert.equal((await store.get(session)).activeMatch?.matchId, match.matchId);
now += 120001; reconnect = true;
await restarted.cleanupDisconnectedMatches();
assert.equal(reconnect, false, 'exercise a reconnect between candidate selection and atomic cleanup');
assert.equal((await store.get(session)).activeMatch?.matchId, match.matchId);
now += 120001; await restarted.cleanupDisconnectedMatches();
assert.equal((await store.get(session)).activeMatch, undefined);
} finally { await db.dropDatabase(); await client.close(); }
});
test('disconnected matches expire without changing stats or cancelling a live tab', { skip: !uri }, async () => {
const client = await new MongoClient(uri!).connect();
const db = client.db(`dragon_ball_test_orphans_${randomUUID().replaceAll('-', '')}`);
try {
let now = Date.now();
const store = await createGameSessionStore(db, () => now);
const offline = await store.create(randomBytes(32).toString('base64url'), 'same-player');
const online = await store.create(randomBytes(32).toString('base64url'), 'same-player');
const register = (auth: typeof offline) => store.register(auth, { requestId: randomUUID(), matchId: randomUUID(), mode: 'bot', expectedPreviousSeq: 0 });
const abandoned = await register(offline), live = await register(online);
now += 90000;
await store.touchPresence(online);
await store.cleanupDisconnectedMatches();
assert.equal((await store.get(offline)).activeMatch?.matchId, abandoned.matchId, 'reconnection grace');
now += 31000;
await store.cleanupDisconnectedMatches();
assert.equal((await store.get(offline)).activeMatch, undefined);
assert.equal((await store.get(online)).activeMatch?.matchId, live.matchId);
assert.deepEqual((await store.get(offline)).stats, offline.stats);
assert.equal((await store.get(offline)).lastAppliedSeq, 1);
assert.equal((await store.record(offline, { ...abandoned, outcome: 'win', reason: 'hit' })).status, 'stale');
assert.deepEqual((await store.get(offline)).stats, offline.stats);
await store.register(offline, { requestId: randomUUID(), matchId: randomUUID(), mode: 'bot', expectedPreviousSeq: 1 });
await store.cleanupDisconnectedMatches();
assert.ok((await store.get(offline)).activeMatch, 'a new match must not inherit an expired lease');
} finally { await db.dropDatabase(); await client.close(); }
});
test('Standalone session counters apply concurrent and late results exactly once', { skip: !uri }, async () => { test('Standalone session counters apply concurrent and late results exactly once', { skip: !uri }, async () => {
const client = await new MongoClient(uri!, { serverSelectionTimeoutMS: 5000 }).connect(); const client = await new MongoClient(uri!, { serverSelectionTimeoutMS: 5000 }).connect();
const db = client.db(`dragon_ball_test_p2p_records_${randomUUID().replaceAll('-', '')}`); const db = client.db(`dragon_ball_test_p2p_records_${randomUUID().replaceAll('-', '')}`);
+13 -2
View File
@@ -9,6 +9,15 @@ import { createGlobalSettingsStore } from '../src/server/global-settings.js';
import { createGameSessionStore } from '../src/server/game-session-store.js'; import { createGameSessionStore } from '../src/server/game-session-store.js';
import { createRoomStore } from '../src/server/rooms.js'; import { createRoomStore } from '../src/server/rooms.js';
import { createMatchEngine } from '../src/shared/match-engine.js'; import { createMatchEngine } from '../src/shared/match-engine.js';
import { isGlobalSettings } from '../src/shared/global-settings.js';
test('ranked acceptance timeout is a configurable positive duration', () => {
assert.equal(DEFAULT_GLOBAL_SETTINGS.rankedAcceptTimeoutMs, 10000);
assert.equal(isGlobalSettings({ ...DEFAULT_GLOBAL_SETTINGS, rankedAcceptTimeoutMs: 5000 }), true);
for (const rankedAcceptTimeoutMs of [0, -1, 60001, 1500.5]) {
assert.equal(isGlobalSettings({ ...DEFAULT_GLOBAL_SETTINGS, rankedAcceptTimeoutMs }), false);
}
});
test('browser caches settings, checks revision and retains valid settings through failures', async () => { test('browser caches settings, checks revision and retains valid settings through failures', async () => {
const memory = new Map<string, string>(), urls: string[] = []; const memory = new Map<string, string>(), urls: string[] = [];
@@ -71,17 +80,19 @@ test('DB settings update without restart and friend matches share an immutable s
const c = await sessions.create(randomBytes(32).toString('base64url')); const c = await sessions.create(randomBytes(32).toString('base64url'));
const bot = await sessions.register(c, { requestId: 'bot', matchId: 'bot', mode: 'bot', expectedPreviousSeq: 0 }); 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); 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 db.collection('global_settings').updateOne({ _id: 'global' as any }, { $unset: { matchingDelayMs: '', matchSuccessDelayMs: '', matchPreparationDelayMs: '', rankedAcceptTimeoutMs: '' } });
await store.initialize(); await store.initialize();
const migrated = await store.get(); const migrated = await store.get();
assert.equal(migrated.matchingDelayMs, 1000); assert.equal(migrated.matchSuccessDelayMs, 1000); assert.equal(migrated.matchingDelayMs, 1000); assert.equal(migrated.matchSuccessDelayMs, 1000);
assert.equal(migrated.matchPreparationDelayMs, 3000); assert.equal(migrated.matchPreparationDelayMs, 3000);
assert.equal(migrated.rankedAcceptTimeoutMs, 10000);
assert.equal(migrated.finishCutsceneSpeed, 3); assert.equal(migrated.turnDurationMs, 2000); assert.equal(migrated.finishCutsceneSpeed, 3); assert.equal(migrated.turnDurationMs, 2000);
assert.ok(migrated.revision > 3); assert.ok(migrated.revision > 3);
await store.initialize(); assert.deepEqual(await store.get(), migrated); await store.initialize(); assert.deepEqual(await store.get(), migrated);
const changed = await store.update({ ...migrated, matchingDelayMs: 4000, matchSuccessDelayMs: 2000, matchPreparationDelayMs: 5000 }); const changed = await store.update({ ...migrated, matchingDelayMs: 4000, matchSuccessDelayMs: 2000, matchPreparationDelayMs: 5000, rankedAcceptTimeoutMs: 7000 });
assert.equal(changed.matchingDelayMs, 4000); assert.equal(changed.matchSuccessDelayMs, 2000); assert.equal(changed.matchingDelayMs, 4000); assert.equal(changed.matchSuccessDelayMs, 2000);
assert.equal((await store.get()).matchPreparationDelayMs, 5000); assert.equal((await store.get()).matchPreparationDelayMs, 5000);
assert.equal((await store.get()).rankedAcceptTimeoutMs, 7000);
await assert.rejects(store.update({ ...changed, matchPreparationDelayMs: -1 }), /INVALID_SETTINGS/); await assert.rejects(store.update({ ...changed, matchPreparationDelayMs: -1 }), /INVALID_SETTINGS/);
await assert.rejects(store.update({ ...changed, matchingDelayMs: -1 }), /INVALID_SETTINGS/); await assert.rejects(store.update({ ...changed, matchingDelayMs: -1 }), /INVALID_SETTINGS/);
} finally { await app.close(); await db.dropDatabase(); await client.close(); } } finally { await app.close(); await db.dropDatabase(); await client.close(); }
+5
View File
@@ -42,6 +42,11 @@ test('HTTP analytics batches deduplicate, throttle activity writes and keep clie
assert.equal(await stats.db.collection('matches').countDocuments(), 0); assert.equal(await stats.db.collection('matches').countDocuments(), 0);
assert.equal((await post('/api/analytics/events', { events: Array(21).fill(events[0]) })).statusCode, 400); assert.equal((await post('/api/analytics/events', { events: Array(21).fill(events[0]) })).statusCode, 400);
assert.equal((await post('/api/analytics/events', { events: [{ eventId: 'fake', eventType: 'match_started' }] })).statusCode, 400); assert.equal((await post('/api/analytics/events', { events: [{ eventId: 'fake', eventType: 'match_started' }] })).statusCode, 400);
assert.equal((await post('/api/analytics/events', { events: [
{ eventId: 'ranked-screen', eventType: 'screen_view', screen: 'ranked-queue' },
{ eventId: 'disconnect-summary', eventType: 'game_finished', matchId: 'disconnected', mode: 'friend', outcome: 'aborted', reason: 'disconnect' },
{ eventId: 'double-disconnect-summary', eventType: 'game_finished', matchId: 'both-disconnected', mode: 'friend', outcome: 'aborted', reason: 'double_disconnect' },
] })).statusCode, 200);
assert.equal((await app.inject({ method: 'POST', url: '/api/analytics/activity', payload: { visible: true } })).statusCode, 401); assert.equal((await app.inject({ method: 'POST', url: '/api/analytics/activity', payload: { visible: true } })).statusCode, 401);
} finally { await app.close(); await analytics.close(); try { await stats.db.dropDatabase(); } finally { await stats.close(); } } } finally { await app.close(); await analytics.close(); try { await stats.db.dropDatabase(); } finally { await stats.close(); } }
}); });
+125 -4
View File
@@ -26,7 +26,9 @@ function documentSurface() {
return this.nodes.get(selector)!; return this.nodes.get(selector)!;
} }
addEventListener(name: string, listener: (event: any) => void) { this.listeners.set(name, listener); } addEventListener(name: string, listener: (event: any) => void) { this.listeners.set(name, listener); }
prepend() {} after() {} append() {} showModal() {} close() {} open = false;
prepend() {} after() {} append() {} setAttribute() {} focus() {}
showModal() { this.open = true; } close() { this.open = false; }
} }
const document = { const document = {
body: new Element(), createElement: () => new Element(), querySelector: () => new Element(), body: new Element(), createElement: () => new Element(), querySelector: () => new Element(),
@@ -38,6 +40,125 @@ function documentSurface() {
return document; return document;
} }
test('first visit waits for a saved nickname and keeps the form open on rejection', async t => {
const document = documentSurface(), original = Object.getOwnPropertyDescriptor(globalThis, 'document');
Object.defineProperty(globalThis, 'document', { configurable: true, value: document });
t.after(() => { if (original) Object.defineProperty(globalThis, 'document', original); else Reflect.deleteProperty(globalThis, 'document'); });
const profile: PlayerProfile = { playerId: 'a', displayName: '', nameTag: 1, appearance: normalizeAppearance(undefined),
stats: { wins: 0, losses: 0, draws: 0, current: 0, best: 0 }, unlockedColors: ['white'], unlockedEquipment: [] };
let writes = 0, initialized = false, rejectSave = true;
const api = { async request(_path: string, method?: string, body?: { displayName: string }) {
if (method === 'POST') {
writes++;
if (rejectSave) throw new Error('저장 실패');
return { ...profile, displayName: body!.displayName };
}
return profile;
} } as unknown as GameApi;
const lobby = createLobbyFeatures({ api, onQueue() {}, onMatch() {}, onHome() {}, onAppearance() {}, onAvailabilityChange() {}, onError(error) { throw error; } });
const loading = lobby.initialize(false).then(() => { initialized = true; });
await Promise.resolve();
const dialog = document.getElementById('nickname-dialog');
assert.equal(dialog.open, true); assert.equal(initialized, false);
let prevented = false;
dialog.listeners.get('cancel')!({ preventDefault() { prevented = true; } });
assert.equal(prevented, true);
const submit = () => dialog.querySelector('form').listeners.get('submit')!({ preventDefault() {} });
dialog.querySelector('input').value = '씨 발'; await submit();
assert.equal(writes, 0); assert.equal(dialog.open, true);
dialog.querySelector('input').value = '손오공'; await submit();
assert.equal(initialized, false); assert.equal(dialog.querySelector('p').textContent, '저장 실패');
rejectSave = false; await submit(); await loading;
assert.equal(initialized, true); assert.equal(dialog.open, false);
assert.equal(document.getElementById('profile-name').textContent, '손오공#0001');
await document.getElementById('nickname-open').onclick!();
assert.equal(dialog.querySelector('input').value, '손오공', 'the tag is not part of the editable nickname');
dialog.querySelector('input').value = '베지터'; await submit();
assert.equal(document.getElementById('profile-name').textContent, '베지터#0001');
});
test('ranked confirmation shows accept, waits for opponent and cancels by offer id', 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'] });
const profile: PlayerProfile = { playerId: 'a', displayName: 'A', nameTag: 1, appearance: normalizeAppearance(undefined),
stats: { wins: 0, losses: 0, draws: 0, current: 0, best: 0 }, unlockedColors: ['white'], unlockedEquipment: [] };
let accepted = false, screen = '', cancelBody: unknown;
const api = { async request(path: string, method?: string, body?: unknown) {
if (path === '/api/ranked/accept') { assert.deepEqual(body, { matchId: 'offer-a' }); accepted = true; }
if (path === '/api/ranked/cancel') { cancelBody = body; return { status: 'idle', profile }; }
return { status: 'confirming', profile, offer: { matchId: 'offer-a', expiresAt: 10000, serverNow: 0, accepted } };
} } as unknown as GameApi;
const lobby = createLobbyFeatures({ api, onQueue(message) { screen = message; }, onMatch() { assert.fail('must wait for both accepts'); },
onHome() { screen = 'home'; }, onAppearance() {}, onAvailabilityChange() {}, onError(error) { throw error; } });
await lobby.initialize(true);
assert.equal(screen, '매칭 성공!');
const accept = document.getElementById('ranked-accept');
assert.equal(accept.disabled, false);
await accept.onclick!();
assert.equal(accept.disabled, true);
assert.equal(document.getElementById('ranked-queue').querySelector('.accept-status').textContent, '상대 수락 대기');
await lobby.leave();
assert.deepEqual(cancelBody, { matchId: 'offer-a' });
assert.equal(screen, 'home');
});
test('a delayed accept response cannot overwrite a restored newer offer', 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'] });
const profile: PlayerProfile = { playerId: 'a', displayName: 'A', nameTag: 1, appearance: normalizeAppearance(undefined),
stats: { wins: 0, losses: 0, draws: 0, current: 0, best: 0 }, unlockedColors: ['white'], unlockedEquipment: [] };
let currentId = 'old', finishAccept!: (value: unknown) => void;
const pendingAccept = new Promise(resolve => { finishAccept = resolve; });
const status = (matchId: string, accepted = false) => ({ status: 'confirming', profile, offer: { matchId, accepted, expiresAt: 10000, serverNow: 0 } });
const bodies: unknown[] = [];
const api = { async request(path: string, method?: string, body?: unknown) {
if (path === '/api/ranked/accept') return pendingAccept;
if (path === '/api/ranked/cancel') { bodies.push(body); return { status: 'idle', profile }; }
return status(currentId);
} } as unknown as GameApi;
const lobby = createLobbyFeatures({ api, onQueue() {}, onMatch() {}, onHome() {}, onAppearance() {}, onAvailabilityChange() {}, onError(error) { throw error; } });
await lobby.initialize(true);
const accepting = document.getElementById('ranked-accept').onclick!();
currentId = 'new'; await lobby.initialize(true);
finishAccept(status('old', true)); await accepting;
assert.equal(document.getElementById('ranked-accept').disabled, false);
await lobby.leave();
assert.deepEqual(bodies, [{ matchId: 'new' }]);
});
test('an old confirmation poll cannot restore the prompt after accepting starts preparation', 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'] });
const profile: PlayerProfile = { playerId: 'a', displayName: 'A', nameTag: 1, appearance: normalizeAppearance(undefined),
stats: { wins: 0, losses: 0, draws: 0, current: 0, best: 0 }, unlockedColors: ['white'], unlockedEquipment: [] };
const confirming = { status: 'confirming', profile, offer: { matchId: 'offer-a', expiresAt: 10000, serverNow: 0, accepted: false } };
const engine = createMatchEngine({ matchId: 'offer-a', mode: 'friend', selfIndex: 0, clock: { now: () => 0 } });
engine.start();
let reads = 0, screen = '', finishPoll!: (value: unknown) => void;
const oldPoll = new Promise(resolve => { finishPoll = resolve; });
const api = { async request(path: string) {
if (path === '/api/ranked/accept') return { status: 'matched', profile, match: { ...engine.view(), ranked: true } };
if (path === '/api/ranked/leave') return { status: 'idle', profile };
return ++reads === 1 ? confirming : oldPoll;
} } as unknown as GameApi;
const lobby = createLobbyFeatures({ api, onQueue(message) { screen = message; }, onMatch() { screen = 'preparing'; },
onHome() { screen = 'home'; }, onAppearance() {}, onAvailabilityChange() {}, onError(error) { throw error; } });
await lobby.initialize(true);
t.mock.timers.tick(RANKED_POLL_MS);
await document.getElementById('ranked-accept').onclick!();
assert.equal(screen, 'preparing');
finishPoll(confirming);
for (let i = 0; i < 5; i++) await Promise.resolve();
assert.equal(screen, 'preparing');
await lobby.leave();
});
for (const initiallyQueued of [false, true]) test(`ranked introduction follows server countdown (queued: ${initiallyQueued})`, async t => { for (const initiallyQueued of [false, true]) test(`ranked introduction follows server countdown (queued: ${initiallyQueued})`, 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 });
@@ -50,7 +171,7 @@ for (const initiallyQueued of [false, true]) test(`ranked introduction follows s
const engine = createMatchEngine({ matchId: 'ranked-intro', mode: 'friend', selfIndex: 0, clock: { now: () => now } }); const engine = createMatchEngine({ matchId: 'ranked-intro', mode: 'friend', selfIndex: 0, clock: { now: () => now } });
engine.start(matchIntroductionDuration(DEFAULT_GLOBAL_SETTINGS, true)); engine.start(matchIntroductionDuration(DEFAULT_GLOBAL_SETTINGS, true));
const profile: PlayerProfile = { const profile: PlayerProfile = {
playerId: 'player-a', displayName: 'Alpha', appearance: normalizeAppearance(undefined), playerId: 'player-a', displayName: 'Alpha', nameTag: 1, appearance: normalizeAppearance(undefined),
stats: { wins: 0, losses: 0, draws: 0, current: 0, best: 0 }, unlockedColors: ['white'], unlockedEquipment: [], stats: { wins: 0, losses: 0, draws: 0, current: 0, best: 0 }, unlockedColors: ['white'], unlockedEquipment: [],
}; };
const screens: string[] = []; const screens: string[] = [];
@@ -97,7 +218,7 @@ test('a profile read started before equip cannot undo the saved appearance', asy
else Reflect.deleteProperty(globalThis, 'document'); else Reflect.deleteProperty(globalThis, 'document');
}); });
const profile = (skinTone: 'light' | 'tan'): PlayerProfile => ({ const profile = (skinTone: 'light' | 'tan'): PlayerProfile => ({
playerId: 'player-a', displayName: 'Alpha', appearance: normalizeAppearance({ characterId: 0, skinTone, colorId: 'white' }), playerId: 'player-a', displayName: 'Alpha', nameTag: 1, appearance: normalizeAppearance({ characterId: 0, skinTone, colorId: 'white' }),
stats: { wins: 0, losses: 0, draws: 0, current: 0, best: 0 }, unlockedColors: ['white'], unlockedEquipment: [], stats: { wins: 0, losses: 0, draws: 0, current: 0, best: 0 }, unlockedColors: ['white'], unlockedEquipment: [],
}); });
let finishOldRead!: (value: PlayerProfile) => void; let finishOldRead!: (value: PlayerProfile) => void;
@@ -151,7 +272,7 @@ for (const editedDuringLoad of [false, true]) {
const cached = normalizeAppearance({ characterId: 0, colorId: 'white' }); const cached = normalizeAppearance({ characterId: 0, colorId: 'white' });
const remote = normalizeAppearance({ characterId: 1, colorId: 'white', equipment: ['shoes'] }); const remote = normalizeAppearance({ characterId: 1, colorId: 'white', equipment: ['shoes'] });
const profile = (appearance: FighterAppearance): PlayerProfile => ({ const profile = (appearance: FighterAppearance): PlayerProfile => ({
playerId: 'player-a', displayName: 'Alpha', appearance, playerId: 'player-a', displayName: 'Alpha', nameTag: 1, appearance,
stats: { wins: 5, losses: 0, draws: 0, current: 5, best: 5 }, stats: { wins: 5, losses: 0, draws: 0, current: 5, best: 5 },
unlockedColors: ['white'], unlockedEquipment: ['shoes', 'wristbands'], unlockedColors: ['white'], unlockedEquipment: ['shoes', 'wristbands'],
}); });
+35 -5
View File
@@ -12,18 +12,26 @@ 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' | 'result') { async function setup(kind: 'expired' | 'orphan' | 'rematch-wait' | 'result' | 'invitation' | 'invitation-expired') {
const invited = kind.startsWith('invitation');
let saveNickname!: () => void;
const nicknameSaved = new Promise<void>(resolve => { saveNickname = resolve; });
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 } : {}) };
const room: any = { id: 'r', code: 'ABCDEF', currentMatchId: 'm', round: 1, selfIndex: 0, status: 'paired', count: 2, expiresAt: Date.now() + 60000, rematch: [kind === 'rematch-wait', false] }; const room: any = { id: 'r', code: 'ABCDEF', currentMatchId: 'm', round: 1, selfIndex: 0, status: 'paired', count: 2, expiresAt: Date.now() + 60000, rematch: [kind === 'rematch-wait', false] };
let open = true, pollId = 0; let open = true, pollId = 0;
mem.set(GAME_ROOM_KEY, JSON.stringify({ gameSessionId: 's', room })); if (!invited) mem.set(GAME_ROOM_KEY, JSON.stringify({ gameSessionId: 's', room }));
if (kind !== 'orphan') mem.set(GAME_CHECKPOINT_KEY, JSON.stringify({ gameSessionId: 's', activeMatch: active, checkpoint: { state: { if (!invited && kind !== 'orphan') mem.set(GAME_CHECKPOINT_KEY, JSON.stringify({ gameSessionId: 's', activeMatch: active, checkpoint: { state: {
matchId: 'm', mode: 'friend', phase: 'finished', selfIndex: 0, version: 1, finished: { saved: true, stats: session.stats, reason: 'hit' }, rematch: room.rematch, saveStatus: 'saved', matchId: 'm', mode: 'friend', phase: 'finished', selfIndex: 0, version: 1, finished: { saved: true, stats: session.stats, reason: 'hit' }, rematch: room.rematch, saveStatus: 'saved',
} } })); } } }));
const send: typeof fetch = async (input, init) => { const send: typeof fetch = async (input, init) => {
const path = String(input); calls.push(path); let data: any = session; const path = String(input); calls.push(path); let data: any = session;
if (path === '/api/rooms/join') {
assert.equal(JSON.parse(String(init?.body)).code, 'ABCDEF');
if (kind === 'invitation-expired') return new Response(JSON.stringify({ ok: false, code: 'ROOM_EXPIRED', message: '방이 만료되었습니다.' }), { status: 410 });
data = { ...room, selfIndex: 1 };
}
if (path === '/api/rooms/r' || path.endsWith('/leave')) { if (path === '/api/rooms/r' || path.endsWith('/leave')) {
if (kind === 'expired') return new Response(JSON.stringify({ ok: false, code: 'ROOM_EXPIRED', message: 'expired' }), { status: 410 }); if (kind === 'expired') return new Response(JSON.stringify({ ok: false, code: 'ROOM_EXPIRED', message: 'expired' }), { status: 410 });
if (path.endsWith('/leave')) open = false; if (path.endsWith('/leave')) open = false;
@@ -41,16 +49,38 @@ async function setup(kind: 'expired' | 'orphan' | 'rematch-wait' | 'result') {
GAME_NAME: '쎄쎄쎄:기공대전', HOME_TELEPORT_APPEARANCE, createGameApi: () => createGameApi(storage, send), GAME_NAME: '쎄쎄쎄:기공대전', HOME_TELEPORT_APPEARANCE, createGameApi: () => createGameApi(storage, send),
createGlobalSettingsCache: () => createGlobalSettingsCache(storage, send), createGlobalSettingsCache: () => createGlobalSettingsCache(storage, send),
createAppFooter: () => ({ setConnection() {}, openRules() {} }), createAppFooter: () => ({ setConnection() {}, openRules() {} }),
installSoundControls() {},
createPresence: () => ({ connect() {} }),
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: () => ({}),
createLobbyFeatures: () => ({ initialize: async () => false, setReady() {}, active: false }), createLobbyFeatures: () => ({ initialize: async () => { if (invited) await nicknameSaved; return false; }, setReady() {}, active: false }),
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); },
}; };
if (invited) ctx.location.search = '?room=abcdef';
vm.createContext(ctx); vm.runInContext(compiled + '\nglobalThis.review={leave,boot,inspect:()=>({ready,currentScreen,currentRoom,currentMatch})};', 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, saveNickname, nodes, open: () => open };
} }
test('an invitation joins the host room immediately after first-time nickname registration', async () => {
const h = await setup('invitation');
assert.equal(h.calls.includes('/api/rooms/join'), false, 'wait for nickname registration');
h.saveNickname(); await flush();
assert.equal(h.calls.filter(path => path === '/api/rooms/join').length, 1);
assert.deepEqual(h.launched, ['m']);
assert.equal(h.ctx.review.inspect().currentRoom.selfIndex, 1);
});
test('an expired invitation shows the join form and error after nickname registration', async () => {
const h = await setup('invitation-expired');
h.saveNickname(); await flush();
assert.equal(h.calls.filter(path => path === '/api/rooms/join').length, 1);
assert.equal(h.ctx.review.inspect().currentScreen, 'join');
assert.equal(h.ctx.review.inspect().ready, true);
assert.equal(h.nodes.get('notice').textContent, '방이 만료되었습니다.');
assert.deepEqual(h.launched, []);
});
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 () => {
const h = await setup('expired'); await h.ctx.review.leave(); const h = await setup('expired'); await h.ctx.review.leave();
assert.equal(h.ctx.review.inspect().currentScreen, 'home'); assert.equal(h.mem.has(GAME_ROOM_KEY), false); assert.equal(h.ctx.review.inspect().currentScreen, 'home'); assert.equal(h.mem.has(GAME_ROOM_KEY), false);
+1 -1
View File
@@ -34,7 +34,7 @@ test('local bot runs countdown, turns, result and persistence without Socket.IO'
}); });
test('timeout records a loss once and local leave records forfeit', async () => { test('timeout records a loss once and local leave records forfeit', async () => {
const h = harness(); const h = harness();
try { await h.controller.start(); await h.clock.advance(7000); assert.equal(h.recorded[0].outcome, 'loss'); assert.equal(h.recorded[0].reason, 'timeout'); } try { await h.controller.start(); await h.clock.advance(12000); assert.equal(h.recorded[0].outcome, 'loss'); assert.equal(h.recorded[0].reason, 'timeout'); }
finally { h.controller.destroy(); } finally { h.controller.destroy(); }
const other = harness(); const other = harness();
try { await other.controller.start(); await other.controller.leave(); assert.equal(other.recorded[0].reason, 'forfeit'); assert.equal(other.recorded[0].outcome, 'loss'); } try { await other.controller.start(); await other.controller.leave(); assert.equal(other.recorded[0].reason, 'forfeit'); assert.equal(other.recorded[0].outcome, 'loss'); }
+23 -1
View File
@@ -39,10 +39,15 @@ test('a turn resolves once and a hit ends the game', () => {
test('only verified missing choices count as timeouts; technical abort leaves outcome separate', () => { test('only verified missing choices count as timeouts; technical abort leaves outcome separate', () => {
const { engine, at } = setup(); engine.start(1000); at(4000); engine.beginTurn(); at(8000); const { engine, at } = setup(); engine.start(1000); at(4000); engine.beginTurn(); at(8000);
engine.resolve([null, 'charge']); engine.resolve([null, 'charge']);
assert.equal(engine.view().phase, 'selecting');
assert.deepEqual(engine.view().timeouts, [1, 0]);
at(engine.view().deadline); engine.resolve([null, 'charge']);
assert.equal(engine.view().phase, 'selecting');
at(engine.view().deadline); engine.resolve([null, 'charge']);
assert.equal(engine.view().finished?.winnerIndex, 1); assert.equal(engine.view().finished?.winnerIndex, 1);
assert.equal(engine.view().finished?.reason, 'timeout'); assert.equal(engine.view().finished?.reason, 'timeout');
const other = setup(); other.engine.start(1000); other.at(4000); other.engine.beginTurn(); other.at(8000); const other = setup(); other.engine.start(1000); other.at(4000); other.engine.beginTurn(); other.at(8000);
other.engine.resolve([null, null]); for (let i = 0; i < 3; i++) { other.at(other.engine.view().deadline); other.engine.resolve([null, null]); }
assert.equal(other.engine.view().finished?.reason, 'double_timeout'); assert.equal(other.engine.view().finished?.reason, 'double_timeout');
const aborted = setup(); aborted.engine.abort('connection_failed'); const aborted = setup(); aborted.engine.abort('connection_failed');
assert.equal(aborted.engine.view().aborted, true); assert.equal(aborted.engine.view().aborted, true);
@@ -86,3 +91,20 @@ test('rollback and recovery keep published revisions increasing so the view acce
other.engine.resumeTurn(start, 5000); other.engine.resumeTurn(start, 5000);
assert.ok(other.engine.view().version > previous); assert.ok(other.engine.view().version > previous);
}); });
test('timeout chances persist across turns and restore while locked actions stay locked', () => {
const { engine, at } = setup(); engine.start(1000); at(4000); engine.beginTurn(); engine.lockAction('guard');
at(engine.view().deadline); engine.resolve(['guard', null]);
assert.equal(engine.view().ownAction, 'guard');
assert.equal(engine.lockAction('charge'), false);
assert.deepEqual(engine.view().timeouts, [0, 1]);
engine.resolve(['guard', 'charge']);
at(engine.view().reveal!.revealEndsAt); engine.beginTurn();
const saved = engine.checkpoint(); engine.restore(saved);
assert.deepEqual(engine.view().timeouts, [0, 1]);
at(engine.view().deadline); engine.resolve([null, null]);
assert.deepEqual(engine.view().timeouts, [1, 2]);
at(engine.view().deadline); engine.resolve([null, null]);
assert.deepEqual(engine.view().timeouts, [2, 3]);
assert.equal(engine.view().finished?.winnerIndex, 0);
});
+113 -4
View File
@@ -1,3 +1,4 @@
import { DEFAULT_GLOBAL_SETTINGS } from '../constants/global-settings.js';
import { test } from 'node:test'; import { test } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { createMatchController } from '../src/client/match-controller.js'; import { createMatchController } from '../src/client/match-controller.js';
@@ -32,7 +33,26 @@ 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, guestStartDelay = 0) { test('peer emotes reach the correct slot without exposing choices or changing the turn', async () => {
const h = setup(), [a, b] = h.controllers;
try {
await Promise.all(h.controllers.map(c => c.start())); await h.clock.advance(4000);
await a.submitAction('charge'); await h.clock.advance(1);
const before = b.view();
assert.equal(a.sendEmote('greet'), true);
assert.equal(a.sendEmote('mistake'), false);
await h.clock.advance(1);
assert.equal(b.view().emotes?.[0]?.id, 'greet');
assert.equal(b.view().emotes?.[0]?.variant, a.view().emotes?.[0]?.variant);
assert.equal(b.view().ownAction, null);
assert.equal(b.view().turnId, before.turnId);
assert.deepEqual(b.view().energy, before.energy);
assert.equal(b.view().phase, 'selecting');
await b.leave();
assert.equal(b.sendEmote('threaten'), false);
} finally { h.close(); }
});
function setup(onRematch?: () => Promise<void>, guestClockOffset = 0, guestStartDelay = 0, turnDurationMs = DEFAULT_GLOBAL_SETTINGS.turnDurationMs) {
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[][] = [[], []];
@@ -42,7 +62,7 @@ function setup(onRematch?: () => Promise<void>, guestClockOffset = 0, guestStart
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, 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; 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', settings: { ...DEFAULT_GLOBAL_SETTINGS, turnDurationMs } }, 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,
}); });
@@ -142,7 +162,7 @@ test('peers whose wall clocks differ by five seconds agree on deadline and timeo
try { try {
await Promise.all(h.controllers.map(c => c.start())); await h.clock.advance(4000); 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(9000);
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');
assert.equal(a.view().finished?.reason, 'timeout'); assert.equal(b.view().finished?.reason, 'timeout'); assert.equal(a.view().finished?.reason, 'timeout'); assert.equal(b.view().finished?.reason, 'timeout');
} finally { h.close(); } } finally { h.close(); }
@@ -161,7 +181,7 @@ test('unrecoverable transport interruption aborts rather than inventing two wins
test('refresh of a mutually confirmed final result saves without reconnecting to the opponent', async () => { test('refresh of a mutually confirmed final result saves without reconnecting to the opponent', async () => {
const h = setup(); const h = setup();
try { try {
await Promise.all(h.controllers.map(c => c.start())); await h.clock.advance(7000); await Promise.all(h.controllers.map(c => c.start())); await h.clock.advance(13000);
const { GAME_CHECKPOINT_KEY } = await import('../constants/recording.js'); const { GAME_CHECKPOINT_KEY } = await import('../constants/recording.js');
const saved = JSON.parse(h.storages[0].getItem(GAME_CHECKPOINT_KEY)); const saved = JSON.parse(h.storages[0].getItem(GAME_CHECKPOINT_KEY));
saved.checkpoint.state.finished.saved = false; saved.checkpoint.state.saveStatus = 'pending'; saved.checkpoint.state.finished.saved = false; saved.checkpoint.state.saveStatus = 'pending';
@@ -242,3 +262,92 @@ test('loss of result agreement cannot display or save a speculative winner durin
assert.deepEqual(h.records.map(r => r.map(x => x.outcome)), [['aborted'], ['aborted']]); assert.deepEqual(h.records.map(r => r.map(x => x.outcome)), [['aborted'], ['aborted']]);
} finally { h.close(); } } finally { h.close(); }
}); });
test('peer timeout extensions preserve hidden choices and cumulative chances across turns', async () => {
const h = setup(), [a, b] = h.controllers;
try {
await Promise.all(h.controllers.map(c => c.start())); await h.clock.advance(4000);
await a.submitAction('charge');
await h.clock.advance(a.view().deadline - h.clock.now() + 1);
assert.deepEqual(a.view().timeouts, [0, 1]);
assert.deepEqual(b.view().timeouts, [0, 1]);
assert.equal(a.view().ownAction, 'charge');
assert.equal(b.view().reveal, undefined);
assert.equal(await b.submitAction('guard'), true); await h.clock.advance(1);
assert.equal(a.view().phase, 'revealing');
await h.clock.advance(1200);
await a.submitAction('guard');
await h.clock.advance(a.view().deadline - h.clock.now() + 1);
assert.deepEqual(b.view().timeouts, [0, 2]);
assert.equal(b.view().phase, 'selecting');
await h.clock.advance(a.view().deadline - h.clock.now() + 1);
assert.equal(a.view().finished?.winnerIndex, 0);
assert.equal(b.view().finished?.winnerIndex, 0);
} finally { h.close(); }
});
test('a guest commitment arriving after the deadline keeps a valid last-moment choice', async () => {
const h = setup(), [a, b] = h.controllers;
try {
await Promise.all(h.controllers.map(c => c.start())); await h.clock.advance(4000);
await a.submitAction('charge'); await h.clock.advance(1);
const held: PeerMessage[] = [];
h.b.intercept = message => { if (['turn:commit', 'turn:reveal', 'turn:timeout'].includes(message.type)) { held.push(message); return true; } return false; };
await h.clock.advance(b.view().deadline - h.clock.now() - 1);
await b.submitAction('guard'); await h.clock.advance(2);
assert.equal(a.view().phase, 'selecting');
h.b.intercept = undefined; for (const message of held) h.b.send(message);
await h.clock.advance(1);
assert.equal(a.view().phase, 'revealing'); assert.equal(b.view().phase, 'revealing');
assert.deepEqual(a.view().timeouts, [0, 0]); assert.deepEqual(b.view().timeouts, [0, 0]);
assert.deepEqual(a.view().reveal?.actions, ['charge', 'guard']);
} finally { h.close(); }
});
test('recovery reconciles a timeout report lost before the other peer receives it', async () => {
const h = setup(), [a, b] = h.controllers;
try {
await Promise.all(h.controllers.map(c => c.start())); await h.clock.advance(4000);
h.a.intercept = message => message.type === 'turn:timeout';
await h.clock.advance(a.view().deadline - h.clock.now() + 1);
assert.deepEqual(a.view().timeouts, [1, 1]); assert.deepEqual(b.view().timeouts, [0, 0]);
h.a.intercept = undefined; h.a.disconnect(); h.b.disconnect();
await h.clock.advance(1000);
assert.equal(a.view().phase, 'selecting'); assert.equal(b.view().phase, 'selecting');
assert.deepEqual(a.view().timeouts, [1, 1]); assert.deepEqual(b.view().timeouts, [1, 1]);
assert.equal(a.view().aborted, false); assert.equal(b.view().aborted, false);
} finally { h.close(); }
});
test('selecting during a ten-second extension does not start the protocol timeout early', async () => {
const h = setup(undefined, 0, 0, 10000), [a, b] = h.controllers;
try {
await Promise.all(h.controllers.map(c => c.start())); await h.clock.advance(4000);
await h.clock.advance(a.view().deadline - h.clock.now() + 1000);
assert.deepEqual(a.view().timeouts, [1, 1]);
await a.submitAction('guard'); await h.clock.advance(4000);
assert.equal(h.a.epoch, 0); assert.equal(h.b.epoch, 0);
assert.equal(a.view().ownAction, 'guard');
assert.equal(await b.submitAction('charge'), true); await h.clock.advance(1);
assert.equal(a.view().phase, 'revealing');
} finally { h.close(); }
});
test('disconnect before terminal timeout agreement cannot refund the third chance', async () => {
const h = setup(), [a, b] = h.controllers;
try {
await Promise.all(h.controllers.map(c => c.start())); await h.clock.advance(4000);
await a.submitAction('guard');
const holdAgreement = (message: PeerMessage) => ['turn:resolved', 'turn:ack', 'turn:present'].includes(message.type);
h.a.intercept = h.b.intercept = holdAgreement;
await h.clock.advance(a.view().deadline - h.clock.now() + 6001);
assert.deepEqual(a.view().timeouts, [0, 3]); assert.deepEqual(b.view().timeouts, [0, 3]);
assert.deepEqual(h.records.map(records => records.length), [0, 0]);
h.a.intercept = h.b.intercept = undefined; h.a.disconnect(); h.b.disconnect();
await h.clock.advance(1000);
assert.equal(a.view().finished?.reason, 'timeout'); assert.equal(b.view().finished?.reason, 'timeout');
assert.equal(a.view().finished?.winnerIndex, 0); assert.equal(b.view().finished?.winnerIndex, 0);
assert.deepEqual(a.view().timeouts, [0, 3]);
assert.equal(h.records[0][0]?.outcome, 'win'); assert.equal(h.records[1][0]?.outcome, 'loss');
} finally { h.close(); }
});
+65
View File
@@ -0,0 +1,65 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createServer } from 'node:http';
import { Server } from 'socket.io';
import { io as client, type Socket } from 'socket.io-client';
import { registerPresence } from '../src/server/presence.js';
import { ApiError, type GameSessionStore } from '../src/server/game-session-store.js';
import { PRESENCE_AUTH_REFRESH_MS } from '../constants/presence.js';
test('presence counts authenticated players once across tabs and removes disconnected players', async () => {
const http = createServer(), io = new Server(http);
registerPresence(io, { async touchPresence(auth: { token: string; gameSessionId: string }) {
if (auth.token !== 'valid') throw new ApiError(401, 'INVALID_SESSION');
return { playerId: auth.gameSessionId.startsWith('a') ? 'a' : 'b' };
} } as unknown as GameSessionStore);
await new Promise<void>(resolve => http.listen(0, '127.0.0.1', resolve));
const url = `http://127.0.0.1:${(http.address() as { port: number }).port}/presence`;
const sockets: Socket[] = [];
const open = (id: string, token = 'valid') => {
const socket = client(url, { autoConnect: false, forceNew: true, auth: { gameSessionId: id, token }, reconnection: false });
sockets.push(socket); return socket;
};
const next = (socket: Socket) => new Promise<number>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('PRESENCE_TIMEOUT')), 3000);
socket.once('presence:count', n => { clearTimeout(timer); resolve(n); });
});
try {
const a = open('a1'), aFirst = next(a); a.connect(); assert.equal(await aFirst, 1);
const secondTab = open('a2'), tabFirst = next(secondTab); secondTab.connect(); assert.equal(await tabFirst, 1);
const b = open('b1'), bFirst = next(b), aUpdate = next(a); b.connect();
assert.equal(await bFirst, 2); assert.equal(await aUpdate, 2);
const bad = open('bad', 'invalid');
const rejected = new Promise<void>(resolve => bad.once('connect_error', () => resolve())); bad.connect(); await rejected;
const lastA = next(secondTab); a.disconnect(); b.disconnect(); assert.equal(await lastA, 1);
} finally { for (const socket of sockets) socket.disconnect(); await new Promise<void>(resolve => io.close(() => resolve())); }
});
test('temporary revalidation errors allow reconnect, invalid sessions remain disconnected', async t => {
let revalidate!: () => void;
const interval = globalThis.setInterval;
t.mock.method(globalThis, 'setInterval', ((fn: () => void, ms: number) => {
if (ms !== PRESENCE_AUTH_REFRESH_MS) return interval(fn, ms);
revalidate = fn;
return { unref() {} };
}) as typeof setInterval);
let failure: 'temporary' | 'invalid' | undefined;
const http = createServer(), io = new Server(http);
registerPresence(io, { async touchPresence() {
if (failure === 'invalid') throw new ApiError(401, 'INVALID_SESSION');
if (failure === 'temporary') { failure = undefined; throw new Error('DB_UNAVAILABLE'); }
return { playerId: 'one' };
} } as unknown as GameSessionStore);
await new Promise<void>(resolve => http.listen(0, '127.0.0.1', resolve));
const socket = client(`http://127.0.0.1:${(http.address() as { port: number }).port}/presence`, { autoConnect: false, reconnectionDelay: 10 });
const connected = () => new Promise<void>(resolve => socket.once('connect', () => resolve()));
try {
const first = connected(); socket.connect(); await first;
const next = connected(); failure = 'temporary'; revalidate(); await next;
assert.equal(socket.connected, true);
const disconnected = new Promise<string>(resolve => socket.once('disconnect', resolve));
failure = 'invalid'; revalidate();
assert.equal(await disconnected, 'io server disconnect');
assert.equal(socket.active, false);
} finally { socket.disconnect(); await new Promise<void>(resolve => io.close(() => resolve())); }
});
+18
View File
@@ -0,0 +1,18 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { formatProfileName, profileNameError } from '../src/shared/profile-name.js';
test('name tags distinguish duplicate nicknames and keep all digits beyond four places', () => {
assert.equal(formatProfileName({ displayName: '손오공', nameTag: 1 }), '손오공#0001');
assert.equal(formatProfileName({ displayName: '손오공', nameTag: 2 }), '손오공#0002');
assert.equal(formatProfileName({ displayName: '손오공', nameTag: 10000 }), '손오공#10000');
assert.equal(profileNameError('손오공#0001'), 'INVALID_PROFILE_NAME');
});
test('nickname validation accepts ordinary names and rejects profanity and separated variants', () => {
for (const name of ['손오공', '무도가', '구름-1234', 'ClassMaster', '별빛 전사']) assert.equal(profileNameError(name), undefined, name);
for (const name of ['시발', '씨 발', '씨-발', 'ㅅㅂ', '병_신', 'FUCK', 'fuck', '씨\u200b발', '시1발']) {
assert.equal(profileNameError(name), 'PROFILE_NAME_PROFANITY', name);
}
for (const name of ['', '가', '가'.repeat(17), '<img>', null, undefined, {}]) assert.equal(profileNameError(name), 'INVALID_PROFILE_NAME');
});
+109 -1
View File
@@ -8,6 +8,7 @@ import { createRankedService } from '../src/server/ranked-service.js';
import type { RoomStore } from '../src/server/rooms.js'; import type { RoomStore } from '../src/server/rooms.js';
import type { Signaling } from '../src/server/signaling.js'; import type { Signaling } from '../src/server/signaling.js';
import { ManualClock } from './helpers.js'; import { ManualClock } from './helpers.js';
import { DEFAULT_GLOBAL_SETTINGS } from '../constants/global-settings.js';
function deferred() { function deferred() {
let resolve!: () => void; let resolve!: () => void;
@@ -23,7 +24,7 @@ function memoryDatabase(now: number) {
{ _id: 'session-a', playerId: 'player-a', expiresAt: new Date(now + 60000) }, { _id: 'session-a', playerId: 'player-a', expiresAt: new Date(now + 60000) },
{ _id: 'session-b', playerId: 'player-b', expiresAt: new Date(now + 60000) }, { _id: 'session-b', playerId: 'player-b', expiresAt: new Date(now + 60000) },
], ],
player_profiles: [], ranked_matches: [], rooms: [], player_profiles: [], ranked_matches: [], rooms: [], global_settings: [],
}; };
const matches = (doc: Record<string, any>, query: Record<string, any> = {}) => const matches = (doc: Record<string, any>, query: Record<string, any> = {}) =>
Object.entries(query).every(([key, value]) => { Object.entries(query).every(([key, value]) => {
@@ -56,6 +57,113 @@ function memoryDatabase(now: number) {
return { db, records }; return { db, records };
} }
test('ranked pairing waits for both accepts and then starts preparation', async () => {
const clock = new ManualClock(), { db, records } = memoryDatabase(clock.now());
const service = await createRankedService(db, clock, 'test');
try {
await service.join('player-a', 'session-a');
const paired = await service.join('player-b', 'session-b');
assert.equal(paired.status, 'confirming');
assert.equal(paired.match, undefined);
assert.equal(paired.offer!.expiresAt - paired.offer!.serverNow, 10000);
const id = paired.offer!.matchId;
await assert.rejects(service.accept('player-a', 'session-b', id), /INVALID_SESSION/);
await assert.rejects(service.action('player-a', 'session-a', id, 1, 'charge'), /NO_ACTIVE_MATCH/);
const first = await service.accept('player-a', 'session-a', id);
assert.equal(first.offer!.accepted, true);
assert.equal(first.status, 'confirming');
const started = await service.accept('player-b', 'session-b', id);
assert.equal(started.status, 'matched');
assert.equal(started.match!.phase, 'countdown');
assert.equal(started.match!.startsAt - clock.now(), started.match!.settings!.matchPreparationDelayMs);
assert.equal((await service.accept('player-b', 'session-b', id)).match!.startsAt, started.match!.startsAt);
assert.equal(records.ranked_matches.length, 0);
} finally { await service.close(); }
});
test('two simultaneous accepts start once and a delayed cancel cannot forfeit a started match', async () => {
const clock = new ManualClock(), { db, records } = memoryDatabase(clock.now());
const service = await createRankedService(db, clock, 'test');
try {
await service.join('player-a', 'session-a');
const offer = (await service.join('player-b', 'session-b')).offer!;
await Promise.all([service.accept('player-a', 'session-a', offer.matchId), service.accept('player-b', 'session-b', offer.matchId)]);
const state = await service.cancel('player-a', 'session-a', offer.matchId);
assert.equal(state.status, 'matched');
assert.equal(state.match!.phase, 'countdown');
assert.equal(records.ranked_matches.length, 0);
await clock.advance(state.match!.startsAt - clock.now());
assert.equal((await service.status('player-a', 'session-a')).match!.turnId, 1);
} finally { await service.close(); }
});
for (const timeout of [false, true]) test(`ranked ${timeout ? 'timeout' : 'cancel'} requeues only the accepting player without awards`, async () => {
const clock = new ManualClock(), { db, records } = memoryDatabase(clock.now());
const service = await createRankedService(db, clock, 'test');
try {
await service.join('player-a', 'session-a');
const paired = await service.join('player-b', 'session-b');
assert.equal(paired.status, 'confirming');
const id = paired.offer!.matchId;
await service.accept('player-a', 'session-a', id);
if (timeout) await clock.advance(10000);
else assert.equal((await service.cancel('player-b', 'session-b')).status, 'idle');
assert.equal((await service.status('player-a', 'session-a')).status, 'queued');
assert.equal((await service.status('player-b', 'session-b')).status, 'idle');
assert.equal((await service.accept('player-b', 'session-b', id)).status, 'idle');
assert.equal(records.ranked_matches.length, 0);
assert.equal((await service.profile('player-a')).stats.wins, 0);
} finally { await service.close(); }
});
test('ranked confirmation snapshots global settings and expires at its server deadline', async () => {
const clock = new ManualClock(), { db, records } = memoryDatabase(clock.now());
records.global_settings.push({ _id: 'global', ...DEFAULT_GLOBAL_SETTINGS, rankedAcceptTimeoutMs: 5000 });
const service = await createRankedService(db, clock, 'test');
try {
await service.join('player-a', 'session-a');
const paired = await service.join('player-b', 'session-b');
assert.equal(paired.offer!.expiresAt - clock.now(), 5000);
records.global_settings[0].rankedAcceptTimeoutMs = 20000;
await clock.advance(4999);
assert.equal((await service.status('player-a', 'session-a')).status, 'confirming');
clock.time++;
assert.equal((await service.accept('player-a', 'session-a', paired.offer!.matchId)).status, 'idle');
assert.equal((await service.status('player-b', 'session-b')).status, 'idle');
assert.equal(records.ranked_matches.length, 0);
} finally { await service.close(); }
});
test('cancelling an expired offer also cancels its automatic requeue', async () => {
const clock = new ManualClock(), { db } = memoryDatabase(clock.now());
const service = await createRankedService(db, clock, 'test');
try {
await service.join('player-a', 'session-a');
const offer = (await service.join('player-b', 'session-b')).offer!;
await service.accept('player-a', 'session-a', offer.matchId);
await clock.advance(10000);
assert.equal((await service.cancel('player-a', 'session-a', offer.matchId)).status, 'idle');
} finally { await service.close(); }
});
test('cancel racing with accept releases the offer and stale requests cannot cancel the next offer', async () => {
const clock = new ManualClock(), { db, records } = memoryDatabase(clock.now());
records.game_sessions.push({ _id: 'session-c', playerId: 'player-c', expiresAt: new Date(clock.now() + 60000) });
const service = await createRankedService(db, clock, 'test');
try {
await service.join('player-a', 'session-a');
const offer = (await service.join('player-b', 'session-b')).offer!;
await service.join('player-c', 'session-c');
await Promise.all([service.accept('player-a', 'session-a', offer.matchId), service.cancel('player-b', 'session-b', offer.matchId)]);
const next = await service.status('player-a', 'session-a');
assert.equal(next.status, 'confirming');
assert.notEqual(next.offer!.matchId, offer.matchId);
assert.equal((await service.cancel('player-a', 'session-a', offer.matchId)).offer!.matchId, next.offer!.matchId);
assert.equal((await service.accept('player-b', 'session-b', offer.matchId)).status, 'idle');
assert.equal(records.ranked_matches.length, 0);
} finally { await service.close(); }
});
for (const url of ['/api/game-session/matches', '/api/rooms', '/api/rooms/join']) { for (const url of ['/api/game-session/matches', '/api/rooms', '/api/rooms/join']) {
test(`${url} reserves the player until its write completes before ranked pairing`, { timeout: 5000 }, async () => { test(`${url} reserves the player until its write completes before ranked pairing`, { timeout: 5000 }, async () => {
const clock = new ManualClock(), { db, records } = memoryDatabase(clock.now()); const clock = new ManualClock(), { db, records } = memoryDatabase(clock.now());
+111 -39
View File
@@ -17,6 +17,48 @@ 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 };
test('orphan cleanup unblocks the same player without blocking independent browsers or live tabs', integration, async () => {
const f = await fixture();
try {
await f.sessions.register(f.a, { requestId: 'bot', matchId: 'bot', mode: 'bot', expectedPreviousSeq: 0 });
assert.equal((await f.service.join('player-b', f.b.gameSessionId)).status, 'queued');
await f.service.cancel('player-b', f.b.gameSessionId);
await assert.rejects(f.service.join('player-a', f.a2.gameSessionId), /MATCH_PENDING/);
f.clock.time += 90000; await f.sessions.touchPresence(f.a);
f.clock.time += 31000; await f.sessions.cleanupDisconnectedMatches();
await assert.rejects(f.service.join('player-a', f.a2.gameSessionId), /MATCH_PENDING/);
f.clock.time += 90000; await f.sessions.cleanupDisconnectedMatches();
assert.equal((await f.service.join('player-a', f.a2.gameSessionId)).status, 'queued');
} finally { await f.close(); }
});
async function pairAndAccept(service: Awaited<ReturnType<typeof createRankedService>>, sessionA: string, sessionB: string) {
const paired = await service.join('player-b', sessionB);
assert.equal(paired.status, 'confirming');
await service.accept('player-a', sessionA, paired.offer!.matchId);
return service.accept('player-b', sessionB, paired.offer!.matchId);
}
test('ranked emotes authenticate membership, reach both players and preserve hidden actions', integration, async () => {
const f = await fixture();
try {
await f.service.join('player-a', f.a.gameSessionId);
const paired = await pairAndAccept(f.service, f.a.gameSessionId, f.b.gameSessionId);
const matchId = paired.match!.matchId;
await f.clock.advance(paired.match!.startsAt - f.clock.now());
await f.service.action('player-a', f.a.gameSessionId, matchId, 1, 'charge');
const before = await f.service.status('player-b', f.b.gameSessionId);
const sent = await f.service.emote('player-a', f.a.gameSessionId, matchId, 'greet');
const received = await f.service.status('player-b', f.b.gameSessionId);
assert.equal(received.match!.emotes?.[0]?.id, 'greet');
assert.ok(received.match!.version > before.match!.version, 'an older poll cannot overwrite this emote');
assert.deepEqual(received.match!.emotes, sent.match!.emotes);
assert.equal(received.match!.ownAction, null);
assert.deepEqual(received.match!.energy, [0, 0]);
await assert.rejects(f.service.emote('player-a', f.a.gameSessionId, matchId, 'thanks'), /INVALID_REQUEST/);
await assert.rejects(f.service.emote('player-b', f.b.gameSessionId, matchId, 'arbitrary text'), /INVALID_REQUEST/);
await assert.rejects(f.service.emote('player-a', f.b.gameSessionId, matchId, 'thanks'), /INVALID_SESSION/);
await assert.rejects(f.service.emote('player-b', f.b.gameSessionId, 'other-match', 'thanks'), /NO_ACTIVE_MATCH/);
} finally { await f.close(); }
});
function observeResultReads(db: Db, afterRead: (filter?: Record<string, unknown>) => Promise<void>) { function observeResultReads(db: Db, afterRead: (filter?: Record<string, unknown>) => Promise<void>) {
const records = db.collection('ranked_matches'); const records = db.collection('ranked_matches');
return new Proxy(db, { get(target, key) { return new Proxy(db, { get(target, key) {
@@ -62,13 +104,13 @@ test('ranked pairing is serialized by player identity across tabs and survives r
const first = await Promise.all([s.join('player-a', a.gameSessionId), s.join('player-a', a2.gameSessionId)]); const first = await Promise.all([s.join('player-a', a.gameSessionId), s.join('player-a', a2.gameSessionId)]);
assert.ok(first.every(value => value.status === 'queued')); assert.ok(first.every(value => value.status === 'queued'));
await assert.rejects(s.assertAvailable('player-a'), /MATCH_PENDING/); await assert.rejects(s.assertAvailable('player-a'), /MATCH_PENDING/);
const paired = await s.join('player-b', b.gameSessionId); const paired = await pairAndAccept(s, a.gameSessionId, b.gameSessionId);
const restored = await s.status('player-a', a2.gameSessionId); const restored = await s.status('player-a', a2.gameSessionId);
assert.equal(paired.match?.matchId, restored.match?.matchId); assert.equal(paired.match?.matchId, restored.match?.matchId);
assert.notEqual(paired.match?.selfIndex, restored.match?.selfIndex); assert.notEqual(paired.match?.selfIndex, restored.match?.selfIndex);
assert.equal(restored.match?.mode, 'friend'); assert.equal(restored.match?.ranked, true); assert.equal(restored.match?.mode, 'friend'); assert.equal(restored.match?.ranked, true);
assert.equal((await s.join('player-a', a.gameSessionId)).match?.matchId, paired.match?.matchId); assert.equal((await s.join('player-a', a.gameSessionId)).match?.matchId, paired.match?.matchId);
await assert.rejects(s.cancel('player-a', a.gameSessionId), /MATCH_PENDING/); assert.equal((await s.cancel('player-a', a.gameSessionId)).status, 'matched');
await assert.rejects(s.status('player-a', b.gameSessionId), /INVALID_SESSION/); await assert.rejects(s.status('player-a', b.gameSessionId), /INVALID_SESSION/);
assert.equal((await s.profile('player-a')).stats.wins, 0); assert.equal((await s.profile('player-a')).stats.wins, 0);
} finally { await f.close(); } } finally { await f.close(); }
@@ -78,7 +120,7 @@ test('bot and friend wins never change ranked standings, profile stats or cosmet
const f = await fixture(); const f = await fixture();
try { try {
await f.service.join('player-a', f.a.gameSessionId); await f.service.join('player-a', f.a.gameSessionId);
const rankedId = (await f.service.join('player-b', f.b.gameSessionId)).match!.matchId; const rankedId = (await pairAndAccept(f.service, f.a.gameSessionId, f.b.gameSessionId)).match!.matchId;
await f.service.leave('player-b', f.b.gameSessionId, rankedId); await f.service.leave('player-b', f.b.gameSessionId, rankedId);
await f.service.leave('player-a', f.a.gameSessionId, rankedId); await f.service.leave('player-a', f.a.gameSessionId, rankedId);
const before = await f.service.profile('player-a'); const before = await f.service.profile('player-a');
@@ -114,23 +156,45 @@ test('bot and friend wins never change ranked standings, profile stats or cosmet
} finally { await f.close(); } } finally { await f.close(); }
}); });
test('profiles keep generated names and validate unlocked appearances, persistence and pairing snapshots', integration, async () => { test('duplicate nicknames receive distinct persistent tags despite concurrent reads and client tag spoofing', integration, async () => {
const f = await fixture();
try {
const reads = await Promise.all(Array.from({ length: 8 }, () => f.service.profile('player-a')));
assert.ok(reads[0].nameTag > 0);
assert.equal(new Set(reads.map(profile => profile.nameTag)).size, 1);
const a = await f.service.updateProfile('player-a', { displayName: '손오공' });
const b = await f.service.updateProfile('player-b', { displayName: '손오공', nameTag: a.nameTag } as any);
assert.equal(a.displayName, b.displayName); assert.notEqual(a.nameTag, b.nameTag);
assert.equal((await f.service.updateProfile('player-a', { displayName: '베지터', nameTag: b.nameTag } as any)).nameTag, a.nameTag);
await assert.rejects(f.db.collection<any>('player_profiles').updateOne({ _id: 'player-b' }, { $set: { nameTag: a.nameTag } }), { code: 11000 });
await f.service.close();
const restarted = await createRankedService(f.db, f.clock, 'test');
try {
assert.equal((await restarted.profile('player-a')).nameTag, a.nameTag);
assert.equal((await restarted.profile('player-b')).nameTag, b.nameTag);
assert.ok((await restarted.profile('player-c')).nameTag > Math.max(a.nameTag, b.nameTag));
} finally { await restarted.close(); }
} finally { await f.close(); }
});
test('profiles save filtered names and validate unlocked appearances, persistence and pairing snapshots', integration, async () => {
const f = await fixture(); const f = await fixture();
try { try {
const s = f.service; const s = f.service;
const original = await s.profile('player-a'); assert.equal((await s.profile('player-a')).displayName, '');
const original = await s.updateProfile('player-a', { displayName: '손오공' });
assert.deepEqual(original.appearance, normalizeAppearance({ characterId: 0, colorId: 'white' })); assert.deepEqual(original.appearance, normalizeAppearance({ characterId: 0, colorId: 'white' }));
assert.deepEqual(original.unlockedEquipment, []); assert.deepEqual(original.unlockedEquipment, []);
assert.match(original.displayName, /^[가-힣]+-[0-9]{4}$/); assert.equal(original.displayName, '손오공');
for (const displayName of ['손오공', '광고사이트', '<img>', null, undefined]) { for (const displayName of ['씨 발', '병_신', '<img>', null, undefined]) {
await assert.rejects(s.updateProfile('player-a', { displayName } as any), /PROFILE_NAME_READONLY/); await assert.rejects(s.updateProfile('player-a', { displayName } as any), /PROFILE_NAME_PROFANITY|INVALID_PROFILE_NAME/);
} }
for (const appearance of [{ characterId: 2, colorId: 'white' }, { characterId: 0, colorId: 'bogus' }, { characterId: 0, colorId: 'orange' }]) { for (const appearance of [{ characterId: 2, colorId: 'white' }, { characterId: 0, colorId: 'bogus' }, { characterId: 0, colorId: 'orange' }]) {
await assert.rejects(s.updateProfile('player-a', { appearance: appearance as any }), /INVALID_APPEARANCE|COLOR_LOCKED/); await assert.rejects(s.updateProfile('player-a', { appearance: appearance as any }), /INVALID_APPEARANCE|COLOR_LOCKED/);
} }
const selected = await s.updateProfile('player-a', { appearance: { characterId: 1, colorId: 'white' } }); const selected = await s.updateProfile('player-a', { appearance: { characterId: 1, colorId: 'white' } });
await s.join('player-a', f.a.gameSessionId); await s.join('player-a', f.a.gameSessionId);
const paired = await s.join('player-b', f.b.gameSessionId); const paired = await pairAndAccept(s, f.a.gameSessionId, f.b.gameSessionId);
await s.updateProfile('player-a', { appearance: { characterId: 0, colorId: 'white' } }); await s.updateProfile('player-a', { appearance: { characterId: 0, colorId: 'white' } });
assert.deepEqual(paired.match?.appearances?.[0], selected.appearance); assert.deepEqual(paired.match?.appearances?.[0], selected.appearance);
assert.equal((await f.db.collection('player_profiles').findOne({ _id: 'player-a' as any }))?.displayName, original.displayName); assert.equal((await f.db.collection('player_profiles').findOne({ _id: 'player-a' as any }))?.displayName, original.displayName);
@@ -144,12 +208,12 @@ test('profiles keep generated names and validate unlocked appearances, persisten
} finally { await f.close(); } } finally { await f.close(); }
}); });
test('startup migrates old custom and default names including nonvisiting leaderboard players exactly once', integration, async () => { test('startup preserves old names and hides invalid leaderboard names without rewriting profiles', integration, async () => {
const f = await fixture(); const f = await fixture();
try { try {
await f.service.updateProfile('player-a', { appearance: { characterId: 1, colorId: 'white' } }); await f.service.updateProfile('player-a', { appearance: { characterId: 1, colorId: 'white' } });
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 pairAndAccept(f.service, f.a.gameSessionId, f.b.gameSessionId)).match!.matchId;
await f.service.leave('player-b', f.b.gameSessionId, id); await f.service.leave('player-b', f.b.gameSessionId, id);
const expectedStats = (await f.service.profile('player-a')).stats; const expectedStats = (await f.service.profile('player-a')).stats;
const records = await f.db.collection('ranked_matches').find().toArray(); const records = await f.db.collection('ranked_matches').find().toArray();
@@ -157,6 +221,7 @@ test('startup migrates old custom and default names including nonvisiting leader
const profiles = f.db.collection<any>('player_profiles'); const profiles = f.db.collection<any>('player_profiles');
await profiles.updateOne({ _id: 'player-a' }, { $set: { displayName: '광고.example' }, $unset: { nameVersion: '' } }); await profiles.updateOne({ _id: 'player-a' }, { $set: { displayName: '광고.example' }, $unset: { nameVersion: '' } });
await profiles.updateOne({ _id: 'player-b' }, { $set: { displayName: '무도가', nameVersion: 0 } }); await profiles.updateOne({ _id: 'player-b' }, { $set: { displayName: '무도가', nameVersion: 0 } });
await profiles.updateMany({}, { $unset: { nameTag: '' } });
const restarted = await createRankedService(f.db, f.clock, 'test'); const restarted = await createRankedService(f.db, f.clock, 'test');
let names: string[]; let names: string[];
try { try {
@@ -164,13 +229,20 @@ test('startup migrates old custom and default names including nonvisiting leader
const board = await restarted.leaderboard('new-viewer'); const board = await restarted.leaderboard('new-viewer');
names = board.entries.map(entry => entry.displayName); names = board.entries.map(entry => entry.displayName);
assert.equal(names.length, 2); assert.equal(names.length, 2);
for (const name of names) assert.match(name, /^[가-힣]+-[0-9]{4}$/); assert.ok(board.entries.every(entry => entry.nameTag > 0));
assert.equal(new Set(board.entries.map(entry => entry.nameTag)).size, 2);
assert.deepEqual(names, ['플레이어', '무도가']);
assert.equal(board.entries[0].wins, 1); assert.equal(board.entries[0].wins, 1);
const profile = await restarted.profile('player-a'); const profile = await restarted.profile('player-a');
assert.deepEqual(profile.stats, expectedStats); assert.deepEqual(profile.stats, expectedStats);
assert.deepEqual(profile.appearance, normalizeAppearance({ characterId: 1, colorId: 'white', skinTone: 'light', hairStyle: 'black' })); assert.deepEqual(profile.appearance, normalizeAppearance({ characterId: 1, colorId: 'white', skinTone: 'light', hairStyle: 'black' }));
assert.deepEqual(await f.db.collection('ranked_matches').find().toArray(), records); assert.deepEqual(await f.db.collection('ranked_matches').find().toArray(), records);
assert.equal((await profiles.findOne({ _id: 'player-b' })).nameVersion, 1); assert.equal((await profiles.findOne({ _id: 'player-b' })).nameVersion, 0);
assert.equal((await profiles.findOne({ _id: 'player-a' })).displayName, '광고.example');
assert.equal(profile.displayName, '');
await restarted.updateProfile('player-a', { displayName: '새이름' });
names = (await restarted.leaderboard('new-viewer')).entries.map(entry => entry.displayName);
assert.deepEqual(names, ['새이름', '무도가']);
} finally { await restarted.close(); } } finally { await restarted.close(); }
const again = await createRankedService(f.db, f.clock, 'test'); const again = await createRankedService(f.db, f.clock, 'test');
try { assert.deepEqual((await again.leaderboard('new-viewer')).entries.map(entry => entry.displayName), names!); } try { assert.deepEqual((await again.leaderboard('new-viewer')).entries.map(entry => entry.displayName), names!); }
@@ -219,7 +291,7 @@ test('equipment rewards use canonical thresholds, toggle independently, persist
const basicEquipment = ['shoes', 'wristbands', 'headband'] as const; const basicEquipment = ['shoes', 'wristbands', 'headband'] as const;
for (let wins = 1; wins <= 20; wins++) { for (let wins = 1; wins <= 20; wins++) {
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 pairAndAccept(s, f.a.gameSessionId, f.b.gameSessionId)).match!.matchId;
await s.leave('player-b', f.b.gameSessionId, id); await s.leave('player-b', f.b.gameSessionId, id);
await s.leave('player-a', f.a.gameSessionId, id); await s.leave('player-a', f.a.gameSessionId, id);
const profile = await s.profile('player-a'); const profile = await s.profile('player-a');
@@ -237,7 +309,7 @@ test('equipment rewards use canonical thresholds, toggle independently, persist
const stored = await f.db.collection<any>('player_profiles').findOne({ _id: 'player-a' }); const stored = await f.db.collection<any>('player_profiles').findOne({ _id: 'player-a' });
assert.deepEqual(stored.appearance, equipped.appearance); assert.deepEqual(stored.appearance, equipped.appearance);
await s.join('player-a', f.a.gameSessionId); await s.join('player-a', f.a.gameSessionId);
const paired = await s.join('player-b', f.b.gameSessionId); const paired = await pairAndAccept(s, f.a.gameSessionId, f.b.gameSessionId);
await s.updateProfile('player-a', { appearance: { ...base, hairStyle: 'default', equipment: [] } }); await s.updateProfile('player-a', { appearance: { ...base, hairStyle: 'default', equipment: [] } });
assert.deepEqual(paired.match!.appearances![0], equipped.appearance); assert.deepEqual(paired.match!.appearances![0], equipped.appearance);
assert.deepEqual((await s.status('player-a', f.a2.gameSessionId)).match!.appearances![0], equipped.appearance); assert.deepEqual((await s.status('player-a', f.a2.gameSessionId)).match!.appearances![0], equipped.appearance);
@@ -283,7 +355,7 @@ test('skin and independent white hair validate, preserve legacy profiles, persis
} }); } });
assert.deepEqual((await f.service.updateProfile('player-a', { appearance: { characterId: 0, colorId: 'white' } })).appearance, selected.appearance); assert.deepEqual((await f.service.updateProfile('player-a', { appearance: { characterId: 0, colorId: 'white' } })).appearance, selected.appearance);
await f.service.join('player-a', f.a.gameSessionId); await f.service.join('player-a', f.a.gameSessionId);
const paired = await f.service.join('player-b', f.b.gameSessionId); const paired = await pairAndAccept(f.service, f.a.gameSessionId, f.b.gameSessionId);
await f.service.updateProfile('player-a', { appearance: { ...selected.appearance, skinTone: 'light', hairStyle: 'black' } }); await f.service.updateProfile('player-a', { appearance: { ...selected.appearance, skinTone: 'light', hairStyle: 'black' } });
assert.deepEqual(paired.match!.appearances![0], selected.appearance); assert.deepEqual(paired.match!.appearances![0], selected.appearance);
assert.deepEqual((await f.service.status('player-a', f.a2.gameSessionId)).match!.appearances![0], selected.appearance); assert.deepEqual((await f.service.status('player-a', f.a2.gameSessionId)).match!.appearances![0], selected.appearance);
@@ -330,7 +402,7 @@ test('ranked eyewear persists after streak loss, rejects locked or dual eyewear,
assert.equal(equipped.displayName, original.displayName); assert.equal(equipped.displayName, original.displayName);
assert.deepEqual((await s.updateProfile('player-a', { appearance: { characterId: 1, colorId: 'white' } })).appearance, equipped.appearance); assert.deepEqual((await s.updateProfile('player-a', { appearance: { characterId: 1, colorId: 'white' } })).appearance, equipped.appearance);
await s.join('player-a', f.a.gameSessionId); await s.join('player-a', f.a.gameSessionId);
const paired = await s.join('player-b', f.b.gameSessionId); const paired = await pairAndAccept(s, f.a.gameSessionId, f.b.gameSessionId);
await s.updateProfile('player-a', { appearance: { ...base, equipment: ['glasses'] } }); await s.updateProfile('player-a', { appearance: { ...base, equipment: ['glasses'] } });
assert.deepEqual((await s.status('player-a', f.a2.gameSessionId)).match!.appearances![0], equipped.appearance); assert.deepEqual((await s.status('player-a', f.a2.gameSessionId)).match!.appearances![0], equipped.appearance);
assert.deepEqual(paired.match!.appearances![0], equipped.appearance); assert.deepEqual(paired.match!.appearances![0], equipped.appearance);
@@ -348,9 +420,9 @@ test('server hides choices, checks costs and deadlines, resolves once and awards
try { try {
const { service: s, a, a2, b, clock } = f; const { service: s, a, a2, b, clock } = f;
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 pairAndAccept(s, a.gameSessionId, 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(DEFAULT_GLOBAL_SETTINGS.matchingDelayMs + DEFAULT_GLOBAL_SETTINGS.matchSuccessDelayMs + MATCH_COUNTDOWN_MS); await clock.advance(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');
@@ -391,8 +463,8 @@ test('finished matches retain results on reload then expire when abandoned', int
const f = await fixture(); const f = await fixture();
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 pairAndAccept(f.service, f.a.gameSessionId, f.b.gameSessionId)).match!.matchId;
await f.clock.advance(DEFAULT_GLOBAL_SETTINGS.matchingDelayMs + DEFAULT_GLOBAL_SETTINGS.matchSuccessDelayMs + MATCH_COUNTDOWN_MS + TURN_DURATION_MS); await f.clock.advance(MATCH_COUNTDOWN_MS + TURN_DURATION_MS * 3);
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);
@@ -431,12 +503,12 @@ test('deadline rejects late actions and duplicate terminal writes survive an amb
const s = await createRankedService(db, f.clock, 'test'); const s = await createRankedService(db, f.clock, 'test');
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 pairAndAccept(s, f.a.gameSessionId, f.b.gameSessionId)).match!.matchId;
await f.clock.advance(DEFAULT_GLOBAL_SETTINGS.matchingDelayMs + DEFAULT_GLOBAL_SETTINGS.matchSuccessDelayMs + MATCH_COUNTDOWN_MS); await f.clock.advance(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); 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 * 3;
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); assert.equal((await s.leaderboard('player-a')).self?.wins, 1);
@@ -474,7 +546,7 @@ test('another authenticated tab keeps the player queue alive after the original
await f.service.join('player-a', f.a.gameSessionId); await f.service.join('player-a', f.a.gameSessionId);
await f.db.collection<any>('game_sessions').updateOne({ _id: f.a.gameSessionId }, { $set: { expiresAt: new Date(f.clock.now() - 1) } }); await f.db.collection<any>('game_sessions').updateOne({ _id: f.a.gameSessionId }, { $set: { expiresAt: new Date(f.clock.now() - 1) } });
assert.equal((await f.service.status('player-a', f.a2.gameSessionId)).status, 'queued'); assert.equal((await f.service.status('player-a', f.a2.gameSessionId)).status, 'queued');
assert.equal((await f.service.join('player-b', f.b.gameSessionId)).status, 'matched'); assert.equal((await f.service.join('player-b', f.b.gameSessionId)).status, 'confirming');
} finally { await f.close(); } } finally { await f.close(); }
}); });
@@ -484,10 +556,10 @@ test('timeouts progress without polling, unlock colors only from authoritative r
const { service: s, a, b, clock } = f; const { service: s, a, b, clock } = f;
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 pairAndAccept(s, a.gameSessionId, b.gameSessionId)).match!.matchId;
await clock.advance(DEFAULT_GLOBAL_SETTINGS.matchingDelayMs + DEFAULT_GLOBAL_SETTINGS.matchSuccessDelayMs + MATCH_COUNTDOWN_MS); await clock.advance(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 * 3);
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');
await s.leave('player-a', a.gameSessionId, id); await s.leave('player-b', b.gameSessionId, id); await s.leave('player-a', a.gameSessionId, id); await s.leave('player-b', b.gameSessionId, id);
} }
@@ -495,7 +567,7 @@ test('timeouts progress without polling, unlock colors only from authoritative r
assert.equal(profile.stats.wins, 5); assert.equal(profile.stats.best, 5); assert.equal(profile.stats.wins, 5); assert.equal(profile.stats.best, 5);
assert.ok(profile.unlockedColors.length > 1); assert.ok(profile.unlockedColors.length > 1);
await s.updateProfile('player-a', { appearance: { characterId: 1, colorId: profile.unlockedColors.find(id => id !== 'white')! } }); await s.updateProfile('player-a', { appearance: { characterId: 1, colorId: profile.unlockedColors.find(id => id !== 'white')! } });
await s.join('player-a', a.gameSessionId); await s.join('player-b', b.gameSessionId); await s.join('player-a', a.gameSessionId); await pairAndAccept(s, a.gameSessionId, b.gameSessionId);
await s.close(); await s.close();
const restarted = await createRankedService(f.db, clock, 'test'); const restarted = await createRankedService(f.db, clock, 'test');
try { assert.equal((await restarted.profile('player-a')).stats.wins, 5); } try { assert.equal((await restarted.profile('player-a')).stats.wins, 5); }
@@ -514,17 +586,17 @@ test('ranked HTTP authenticates identity and ignores client results for ranked a
appearance: { characterId: 0, colorId: 'white', equipment: ['headband'] }, stats: { wins: 999, best: 999 }, appearance: { characterId: 0, colorId: 'white', equipment: ['headband'] }, stats: { wins: 999, best: 999 },
} }); } });
assert.equal(locked.statusCode, 403); assert.equal(locked.json().code, 'EQUIPMENT_LOCKED'); assert.equal(locked.statusCode, 403); assert.equal(locked.json().code, 'EQUIPMENT_LOCKED');
for (const displayName of ['광고사이트', '<script>alert(1)</script>', '', null, { nameVersion: 1 }]) { for (const displayName of ['씨발', '<script>alert(1)</script>', '', null, { nameVersion: 1 }]) {
const rejected = await app.inject({ method: 'POST', url: '/api/profile', headers, payload: { const rejected = await app.inject({ method: 'POST', url: '/api/profile', headers, payload: {
displayName, nameVersion: 1, appearance: { characterId: 1, colorId: 'white' }, displayName, nameVersion: 1, appearance: { characterId: 1, colorId: 'white' },
} }); } });
assert.equal(rejected.statusCode, 403); assert.equal(rejected.json().code, 'PROFILE_NAME_READONLY'); assert.equal(rejected.statusCode, 400); assert.match(rejected.json().code, /PROFILE_NAME_PROFANITY|INVALID_PROFILE_NAME/);
} }
assert.deepEqual(await f.service.profile('player-a'), original); assert.deepEqual(await f.service.profile('player-a'), original);
const profile = await app.inject({ method: 'POST', url: '/api/profile', headers, payload: { playerId: 'player-b', stats: { wins: 500 } } }); const profile = await app.inject({ method: 'POST', url: '/api/profile', headers, payload: { playerId: 'player-b', stats: { wins: 500 } } });
assert.equal(profile.statusCode, 200); assert.equal(profile.json().data.playerId, 'player-a'); assert.equal(profile.statusCode, 200); assert.equal(profile.json().data.playerId, 'player-a');
await app.inject({ method: 'POST', url: '/api/ranked/queue', headers }); await app.inject({ method: 'POST', url: '/api/ranked/queue', headers });
const paired = await f.service.join('player-b', f.b.gameSessionId); const paired = await pairAndAccept(f.service, f.a.gameSessionId, f.b.gameSessionId);
const report = await app.inject({ method: 'POST', url: '/api/game-session/results', headers, payload: { const report = await app.inject({ method: 'POST', url: '/api/game-session/results', headers, payload: {
matchId: paired.match!.matchId, seq: 1, mode: 'friend', outcome: 'win', reason: 'hit', matchId: paired.match!.matchId, seq: 1, mode: 'friend', outcome: 'win', reason: 'hit',
} }); } });
@@ -563,8 +635,8 @@ test('a slow leaderboard scan does not block match actions or deadline processin
const s = await createRankedService(db, f.clock, 'test'); const s = await createRankedService(db, f.clock, 'test');
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 pairAndAccept(s, f.a.gameSessionId, f.b.gameSessionId)).match!.matchId;
await f.clock.advance(DEFAULT_GLOBAL_SETTINGS.matchingDelayMs + DEFAULT_GLOBAL_SETTINGS.matchSuccessDelayMs + MATCH_COUNTDOWN_MS); await f.clock.advance(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>;
@@ -606,7 +678,7 @@ test('ranked polls reuse stats and concurrent leaderboard requests share a scan
await Promise.all(Array.from({ length: 8 }, () => s.leaderboard('player-a'))); await Promise.all(Array.from({ length: 8 }, () => s.leaderboard('player-a')));
assert.equal(boards, 1); assert.equal(boards, 1);
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 pairAndAccept(s, f.a.gameSessionId, f.b.gameSessionId)).match!.matchId;
await s.leave('player-b', f.b.gameSessionId, id); await s.leave('player-b', f.b.gameSessionId, id);
const profile = await s.profile('player-a'); const profile = await s.profile('player-a');
assert.equal(profile.stats.wins, 1); assert.equal(profile.stats.wins, 1);
@@ -651,7 +723,7 @@ test('save during stalled ranked reads returns one snapshot without retrying or
const s = await createRankedService(db, f.clock, 'test'); const s = await createRankedService(db, f.clock, 'test');
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 pairAndAccept(s, f.a.gameSessionId, f.b.gameSessionId)).match!.matchId;
hold = true; hold = true;
const board = s.leaderboard('player-a'), spectator = s.profile('spectator'); const board = s.leaderboard('player-a'), spectator = s.profile('spectator');
await started; await started;
@@ -715,7 +787,7 @@ test('a personal history read overlapping its own result save cannot repopulate
const s = await createRankedService(db, f.clock, 'test'); const s = await createRankedService(db, f.clock, 'test');
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 pairAndAccept(s, f.a.gameSessionId, f.b.gameSessionId)).match!.matchId;
// Expire the cached stats without running timers; leave settles the overdue draw. // Expire the cached stats without running timers; leave settles the overdue draw.
f.clock.time += RANKED_STATS_CACHE_TTL_MS; f.clock.time += RANKED_STATS_CACHE_TTL_MS;
hold = true; hold = true;
@@ -755,8 +827,8 @@ test('ranked timer reports only a safe failure code and retries persistence even
}); });
try { try {
await s.join('player-a', f.a.gameSessionId); await s.join('player-a', f.a.gameSessionId);
await s.join('player-b', f.b.gameSessionId); await pairAndAccept(s, f.a.gameSessionId, f.b.gameSessionId);
await f.clock.advance(DEFAULT_GLOBAL_SETTINGS.matchingDelayMs + DEFAULT_GLOBAL_SETTINGS.matchSuccessDelayMs + MATCH_COUNTDOWN_MS + TURN_DURATION_MS); await f.clock.advance(MATCH_COUNTDOWN_MS + TURN_DURATION_MS * 3);
await logged; await logged;
assert.deepEqual(codes, ['RANKED_BACKGROUND_FAILED']); assert.deepEqual(codes, ['RANKED_BACKGROUND_FAILED']);
assert.equal(await records.countDocuments(), 0); assert.equal(await records.countDocuments(), 0);
+34
View File
@@ -6,6 +6,40 @@ import { io } from 'socket.io-client';
import { createGameServer } from '../src/server/create-server.js'; import { createGameServer } from '../src/server/create-server.js';
import { ManualClock } from './helpers.js'; import { ManualClock } from './helpers.js';
const uri = process.env.TEST_MONGODB_URI; const uri = process.env.TEST_MONGODB_URI;
test('server cleanup releases a closed browser while an authenticated presence connection protects another tab', { skip: !uri }, async () => {
const databaseName = 'dragon_ball_test_cleanup_' + randomUUID().replaceAll('-', ''), clock = new ManualClock();
const app = await createGameServer({ mongoUri: uri!, databaseName, clientDirectory: 'dist/client', clock });
const client = await new MongoClient(uri!).connect();
let socket: ReturnType<typeof io> | undefined;
try {
const url = await app.listen({ host: '127.0.0.1', port: 0 });
const create = async (guestToken?: string) => (await app.inject({ method: 'POST', url: '/api/game-sessions',
payload: { createRequestId: randomBytes(32).toString('base64url'), guestToken } })).json().data;
const old = await create(), live = await create(old.guestToken), otherBrowser = await create();
assert.equal(old.playerId, live.playerId);
assert.notEqual(old.playerId, otherBrowser.playerId);
const headers = (s: typeof old) => ({ authorization: 'Bearer ' + s.token, 'x-game-session': s.gameSessionId });
for (const session of [old, live]) {
assert.equal((await app.inject({ method: 'POST', url: '/api/game-session/matches', headers: headers(session),
payload: { requestId: 'bot', matchId: session.gameSessionId, mode: 'bot', expectedPreviousSeq: 0 } })).statusCode, 200);
}
assert.equal((await app.inject({ method: 'POST', url: '/api/ranked/queue', headers: headers(otherBrowser), payload: {} })).statusCode, 200);
await clock.advance(90000);
socket = io(url + '/presence', { autoConnect: false, reconnection: false, auth: { gameSessionId: live.gameSessionId, token: live.token } });
const connected = new Promise<void>((resolve, reject) => { socket!.once('connect', resolve); socket!.once('connect_error', reject); });
socket.connect(); await connected;
await clock.advance(60000);
const get = async (s: typeof old) => (await app.inject({ url: '/api/game-session', headers: headers(s) })).json().data;
assert.equal((await get(old)).activeMatch, undefined);
assert.equal((await get(old)).stats.bot.losses, 0);
assert.equal((await get(live)).activeMatch.matchId, live.gameSessionId);
assert.equal((await app.inject({ method: 'POST', url: '/api/ranked/queue', headers: headers(old), payload: {} })).json().code, 'MATCH_PENDING');
socket.disconnect();
await clock.advance(90000);
assert.equal((await get(live)).activeMatch, undefined);
assert.equal((await app.inject({ method: 'POST', url: '/api/ranked/queue', headers: headers(old), payload: {} })).statusCode, 200);
} finally { socket?.disconnect(); await app.close(); await client.db(databaseName).dropDatabase(); await client.close(); }
});
test('HTTP game registration survives server restart without game timers and old gameplay sockets are rejected', { skip: !uri }, async () => { test('HTTP game registration survives server restart without game timers and old gameplay sockets are rejected', { skip: !uri }, async () => {
const databaseName = 'dragon_ball_test_server_' + randomUUID().replaceAll('-', ''), clock = new ManualClock(); const databaseName = 'dragon_ball_test_server_' + randomUUID().replaceAll('-', ''), clock = new ManualClock();
+25
View File
@@ -0,0 +1,25 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createSound } from '../src/client/sound.js';
test('audio starts only after unlock, deduplicates events, stops on mute and persists preference', async () => {
let created = 0, started = 0, stopped = 0;
const values = new Map<string, string>();
const param = () => ({ value: 0, setValueAtTime() {}, exponentialRampToValueAtTime() {}, linearRampToValueAtTime() {} });
const node = () => ({ connect() {}, disconnect() {}, gain: param(), frequency: param(), pan: param(),
start() { started++; }, stop() { stopped++; }, onended: null });
const context = { state: 'suspended', currentTime: 0, sampleRate: 44100, destination: {},
async resume() { this.state = 'running'; }, createGain: node, createOscillator: node,
createStereoPanner: node, createDynamicsCompressor: node,
};
const sound = createSound({ context: () => { created++; return context as unknown as AudioContext; },
storage: { getItem: key => values.get(key) ?? null, setItem: (key, value) => { values.set(key, value); } } });
sound.play('click'); assert.equal(created, 0);
await sound.unlock(); assert.equal(created, 1);
sound.play('click', 'one'); const count = started;
sound.play('click', 'one'); assert.equal(started, count);
sound.play('click', 'two'); assert.ok(started > count);
sound.setMuted(true); assert.ok(stopped > 0);
const mutedCount = started; sound.play('click'); assert.equal(started, mutedCount);
assert.ok([...values.values()].includes('true'));
});
+26
View File
@@ -1,7 +1,33 @@
import { defineConfig } from 'vite'; import { defineConfig } from 'vite';
import { resolve } from 'node:path'; import { resolve } from 'node:path';
import { GAME_NAME, GAME_DESCRIPTION, GAME_URL, GAME_SHARE_IMAGE } from './constants/branding.js';
export default defineConfig({ export default defineConfig({
root: 'src/client', root: 'src/client',
plugins: [{
name: 'game-share-metadata',
transformIndexHtml(_html, context) {
if (context.path !== '/index.html' && context.path !== '/') return [];
return [
{ name: 'description', content: GAME_DESCRIPTION },
{ property: 'og:type', content: 'website' },
{ property: 'og:locale', content: 'ko_KR' },
{ property: 'og:site_name', content: GAME_NAME },
{ property: 'og:title', content: GAME_NAME },
{ property: 'og:description', content: GAME_DESCRIPTION },
{ property: 'og:url', content: GAME_URL },
{ property: 'og:image', content: GAME_SHARE_IMAGE },
{ property: 'og:image:type', content: 'image/png' },
{ property: 'og:image:width', content: '1200' },
{ property: 'og:image:height', content: '630' },
{ property: 'og:image:alt', content: `${GAME_NAME} 타이틀과 세 캐릭터` },
{ name: 'twitter:card', content: 'summary_large_image' },
{ name: 'twitter:title', content: GAME_NAME },
{ name: 'twitter:description', content: GAME_DESCRIPTION },
{ name: 'twitter:image', content: GAME_SHARE_IMAGE },
{ name: 'twitter:image:alt', content: `${GAME_NAME} 타이틀과 세 캐릭터` },
].map(attrs => ({ tag: 'meta', attrs, injectTo: 'head' as const }));
},
}],
build: { outDir: '../../dist/client', emptyOutDir: true, build: { outDir: '../../dist/client', emptyOutDir: true,
rollupOptions: { input: { game: resolve('src/client/index.html'), admin: resolve('src/client/admin/analytics/index.html') } }, rollupOptions: { input: { game: resolve('src/client/index.html'), admin: resolve('src/client/admin/analytics/index.html') } },
}, },