Compare commits
11
Commits
293ed7bcc4
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b129d9c77b | ||
|
|
794cc4da04 | ||
|
|
fc45534c7d | ||
|
|
5cef1c6f1e | ||
|
|
6f44ec0d15 | ||
|
|
65c3941e29 | ||
|
|
dd914d7181 | ||
|
|
c744e8c2a1 | ||
|
|
3540f392b0 | ||
|
|
d87f5bc1db | ||
|
|
1a1f29e397 |
@@ -1,5 +1,12 @@
|
||||
# 프로젝트 작업 규칙
|
||||
|
||||
## 작업 완료 후 커밋
|
||||
|
||||
- 매 작업을 완료할 때 관련 검증을 수행한 뒤 해당 작업의 변경 사항을 반드시 커밋한다.
|
||||
- 별도의 커밋 요청이나 확인을 기다리지 않는다. 사용자가 명시적으로 커밋하지 말라고 요청한 경우는 예외로 한다.
|
||||
- 해당 작업과 무관한 기존 변경 사항은 커밋에 포함하지 않는다.
|
||||
- 완료 보고에 커밋 해시와 검증 결과를 명시한다. 푸시는 사용자가 요청한 경우에 수행한다.
|
||||
|
||||
## UI 문구 최소화
|
||||
|
||||
화면 문구는 사용자의 행동과 판단에 필요한 정보만 표시한다.
|
||||
|
||||
@@ -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` 캐시를 갱신한다. 조회 실패 시 유효한 캐시 또는 기본값을 유지하며 실제 대전에는 서버가 확정한 설정을 사용한다.
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
이벤트·방문 세션·활동 표본은 90일 보관한다. 원문 UA·URL·방 코드·인증 토큰·IP·정밀 위치·오류 본문은 수집하지 않는다. 유입 도메인과 허용 캠페인 분류만 보관한다. 분석 전송 대기는 클라이언트 최대 100건·30분·6회 시도, 서버 최대 100건·30분이다. 분석 장애는 게임 진행과 결과 집계를 막지 않는다.
|
||||
이벤트·방문 세션은 90일, 활동 표본과 수집 오류 이력은 7일 보관한다. 활동은 10초마다 측정하고 5분마다 최대 활성 방문자 수·측정 횟수·수집 오류 누적값을 저장한다. 정상 종료 시 남은 구간도 저장하며, 비정상 종료 시 아직 저장하지 않은 구간은 유실될 수 있다. 표본 조회 기간은 집계 종료 시각을 기준으로 한다. 서버 시작 시 기존 활동 표본의 만료일도 기록 시각 + 7일로 단축한다. 원문 UA·URL·방 코드·인증 토큰·IP·정밀 위치·오류 본문은 수집하지 않는다. 유입 도메인과 허용 캠페인 분류만 보관한다. 분석 전송 대기는 클라이언트 최대 100건·30분·6회 시도, 서버 최대 100건·30분이다. 분석 장애는 게임 진행과 결과 집계를 막지 않는다.
|
||||
|
||||
## 검증
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@ export const ACTIVITY_INTERVAL_MS = 60000;
|
||||
export const ANALYTICS_BATCH_SIZE = 20;
|
||||
export const ACTIVE_WINDOW_MS = 90000;
|
||||
export const PRESENCE_INTERVAL_MS = 10000;
|
||||
export const 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 RETRY_QUEUE_LIMIT = 100;
|
||||
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 RETENTION_SWEEP_MS = 60 * 60 * 1000;
|
||||
export const CLIENT_EVENTS = ['page_view', 'screen_view', 'ui_action', 'client_error', 'game_started', 'game_finished'] as const;
|
||||
export const SCREENS = ['home', 'join', 'waiting', 'match', 'result'] as const;
|
||||
export const 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 CLIENT_ERROR_CODES = ['storage_unavailable', 'connection_failed', 'renderer_failed'] as const;
|
||||
export const CAMPAIGN_VALUES = ['friend', 'organic', 'social', 'email', 'launch'] as const;
|
||||
|
||||
@@ -1 +1,4 @@
|
||||
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`;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export const GLOBAL_SETTINGS_CACHE_KEY = 'dragon-ball:global-settings:v1';
|
||||
export const GLOBAL_SETTINGS_REQUEST_TIMEOUT_MS = 5000;
|
||||
export const DEFAULT_GLOBAL_SETTINGS = { revision: 1, turnDurationMs: 3000, finishCutsceneSpeed: 1, matchingDelayMs: 1000, matchSuccessDelayMs: 1000, matchPreparationDelayMs: 3000 };
|
||||
export const GLOBAL_SETTINGS_LIMITS = { turnDurationMs: { min: 500, max: 60000 }, finishCutsceneSpeed: { min: 0.25, max: 4 }, matchingDelayMs: { min: 0, max: 10000 }, matchSuccessDelayMs: { min: 0, max: 10000 }, matchPreparationDelayMs: { min: 0, max: 10000 } };
|
||||
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 }, rankedAcceptTimeoutMs: { min: 1000, max: 60000 } };
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
export const ACTION_SHORTCUTS = {
|
||||
charge: 'Q',
|
||||
blast: 'W',
|
||||
guard: 'E',
|
||||
beam: 'R',
|
||||
dodge: 'T',
|
||||
} as const;
|
||||
@@ -1,2 +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;
|
||||
|
||||
@@ -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;
|
||||
@@ -7,8 +7,3 @@ export const RANKED_STATS_CACHE_LIMIT = 1000;
|
||||
export const RANKED_STATS_CACHE_TTL_MS = 5 * 60 * 1000;
|
||||
export const RANKED_LEADERBOARD_CACHE_LIMIT = 1000;
|
||||
export const RANKED_LEADERBOARD_CACHE_TTL_MS = 5000;
|
||||
export const PROFILE_NAME_VERSION = 1;
|
||||
export const PROFILE_NAME_PREFIXES = ['푸른', '밝은', '고요한', '빛나는', '온화한', '맑은', '은빛', '씩씩한'] as const;
|
||||
export const PROFILE_NAME_NOUNS = ['구름', '바람', '별빛', '노을', '하늘', '달빛', '물결', '나무'] as const;
|
||||
export const PROFILE_NAME_SUFFIX_MIN = 1000;
|
||||
export const PROFILE_NAME_SUFFIX_MAX = 10000;
|
||||
|
||||
@@ -15,7 +15,8 @@ export const GAME_API_MESSAGES: Record<string, string> = {
|
||||
ROOM_EXPIRED: '방이 만료되었습니다. 새 방을 만드세요.',
|
||||
SERVER_ERROR: '처리하지 못했습니다. 다시 시도하세요.',
|
||||
INVALID_PROFILE: '프로필 정보를 확인하세요.',
|
||||
PROFILE_NAME_READONLY: '이름은 자동으로 지정되며 변경할 수 없습니다.',
|
||||
INVALID_PROFILE_NAME: '닉네임은 한글·영문·숫자·공백·_·-로 2~16자 입력하세요.',
|
||||
PROFILE_NAME_PROFANITY: '사용할 수 없는 표현이 포함되어 있습니다. 다른 닉네임을 입력하세요.',
|
||||
INVALID_APPEARANCE: '캐릭터, 머리, 도복색과 장비를 확인하세요.',
|
||||
COLOR_LOCKED: '아직 획득하지 않은 도복입니다.',
|
||||
EQUIPMENT_LOCKED: '아직 획득하지 않은 장비입니다.',
|
||||
|
||||
+1
-1
@@ -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 days = section('일별 추이', 'KST 날짜 · 방문자/플레이어는 일별 중복 제거'); chart(days, report.daily, 'visitors', 'day', '#326dde'); table(days, ['날짜', '방문자', '플레이어', '생성 대전', '페이지 조회'], report.daily.map((d: any) => [d.day, d.visitors, d.players, d.matches, d.pageViews]));
|
||||
const hours = section('시간별 추이', 'KST 시각 · 파란 막대: 순방문자'); chart(hours, report.hourly, 'visitors', 'hour', '#326dde'); table(hours, ['시간', '순방문자', '생성 대전'], report.hourly.map((d: any) => [d.hour.replace('T', ' '), d.visitors, d.matches]));
|
||||
const peak = section('최대 동시 이용 표본', '10초 간격 표본 최대값 · 순간 최대값 미측정'); cards(peak, [['표본 수', report.sampledPeak.count], ['활성 방문자 추정', report.sampledPeak.active]]);
|
||||
const peak = section('최대 동시 이용 표본', '최근 7일 · 10초 측정 · 5분 집계 · 집계 종료 시각 기준 · 순간 최대값 미측정'); cards(peak, [['표본 수', report.sampledPeak.count], ['활성 방문자 추정', report.sampledPeak.active]]);
|
||||
} else if (tab === 'acquisition') {
|
||||
for (const [key, label] of [['source', '유입 채널'], ['referrer', '외부 유입 도메인'], ['device', '기기'], ['browser', '브라우저']]) {
|
||||
table(section(label), ['분류', '방문자', '페이지 조회'], report.acquisition[key].map((g: any) => [g.name, g.visitors, g.pageViews]));
|
||||
|
||||
@@ -6,6 +6,7 @@ body.player-app .app > footer.app-footer {
|
||||
width: 100vw;
|
||||
align-self: center;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 4px;
|
||||
|
||||
@@ -175,4 +175,3 @@ body.player-app:is([data-screen=home], [data-screen=join]) #reconnect { position
|
||||
body.player-app .home-actions > .primary { min-height: 68px; font-size: 28px; }
|
||||
body.player-app .home .stat-mode > div { gap: 16px; }
|
||||
}
|
||||
.online-count { position: absolute; top: max(16px, env(safe-area-inset-top)); right: max(20px, env(safe-area-inset-right)); z-index: 5; color: #45534a; font-size: 13px; font-variant-numeric: tabular-nums; }
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"><meta name="theme-color" content="#ffffff"><title>쎄쎄쎄:기공대전</title></head>
|
||||
<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">
|
||||
<span id="online-count" class="online-count" role="status">접속 확인 중</span>
|
||||
<div class="app">
|
||||
<main id="main">
|
||||
<section id="home" class="home">
|
||||
<span id="online-count" class="online-count" role="status">접속 확인 중</span>
|
||||
<div class="home-stage"><h1 aria-label="쎄쎄쎄:기공대전"><span>쎄쎄쎄</span><span>:기공대전</span></h1><div id="home-fighters" aria-hidden="true"></div></div>
|
||||
<div class="home-menu">
|
||||
<div class="home-actions"><button id="bot-start" class="primary" disabled><span>봇과 대전</span><svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M4 12h15m-6-6 6 6-6 6" stroke="currentColor" stroke-width="3" stroke-linecap="square" stroke-linejoin="miter"/></svg></button><div class="split"><button id="room-create" disabled><span>방 만들기</span><svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M12 4v16M4 12h16" stroke="currentColor" stroke-width="2"/></svg></button><button id="join-open" disabled><span>방 참가</span><svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M13 4h7v16h-7M3 12h12m-5-5 5 5-5 5" stroke="currentColor" stroke-width="2"/></svg></button></div></div>
|
||||
|
||||
@@ -1,4 +1,23 @@
|
||||
.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; }
|
||||
#home .home-actions > button:not(.primary) { min-height: 64px; font-size: 26px; }
|
||||
#home .home-actions .split { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
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 { 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 type { Leaderboard, PlayerProfile, RankedStatus } from '../shared/ranked-types.js';
|
||||
import type { ActionId } from '../shared/types.js';
|
||||
@@ -20,16 +23,18 @@ interface LobbyOptions {
|
||||
export function createLobbyFeatures(options: LobbyOptions) {
|
||||
const { api } = options;
|
||||
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');
|
||||
rankedStart.id = 'ranked-start'; rankedStart.className = 'primary'; rankedStart.disabled = true;
|
||||
rankedStart.innerHTML = '<span>랭킹 대전</span><span aria-hidden="true">→</span>';
|
||||
actions.prepend(rankedStart);
|
||||
document.getElementById('bot-start')!.classList.remove('primary');
|
||||
const tools = document.createElement('div'); tools.className = 'lobby-tools';
|
||||
tools.innerHTML = '<button id="leaderboard-open" disabled>리더보드</button><button id="appearance-open" disabled>캐릭터·장비</button>';
|
||||
tools.innerHTML = '<button id="leaderboard-open" disabled>리더보드</button><button id="appearance-open" disabled>캐릭터·장비</button><button id="nickname-open" disabled>닉네임 변경</button>';
|
||||
actions.after(tools);
|
||||
const queue = document.createElement('section'); queue.id = 'ranked-queue'; queue.className = 'waiting ranked-queue'; queue.hidden = true;
|
||||
queue.innerHTML = '<div class="queue-fighter" aria-hidden="true"></div><h1 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);
|
||||
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>';
|
||||
@@ -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>';
|
||||
document.body.append(appearanceDialog, leaderboardDialog);
|
||||
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 equip = select<HTMLButtonElement>(appearanceDialog, '.equip');
|
||||
const appearanceOpen = document.getElementById('appearance-open') as HTMLButtonElement;
|
||||
const leaderboardOpen = document.getElementById('leaderboard-open') as HTMLButtonElement;
|
||||
const cancel = document.getElementById('ranked-cancel') as HTMLButtonElement;
|
||||
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 latest: RankedStatus | undefined, active = false, busy = false, profileBusy = false, timer: ReturnType<typeof setTimeout> | undefined, generation = 0;
|
||||
let profileRevision = 0;
|
||||
@@ -51,6 +95,8 @@ export function createLobbyFeatures(options: LobbyOptions) {
|
||||
function updateProfile(next: PlayerProfile) {
|
||||
const appearanceChanged = !profile || appearanceKey(profile.appearance) !== appearanceKey(next.appearance);
|
||||
profile = next;
|
||||
profileName.textContent = next.displayName ? formatProfileName(next) : '';
|
||||
profileName.hidden = !next.displayName;
|
||||
if (appearanceChanged) {
|
||||
options.onAppearance(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 : '프로필을 불러오지 못했습니다.'; }
|
||||
};
|
||||
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();
|
||||
try {
|
||||
const board = await api.request<Leaderboard>('/api/ranked/leaderboard');
|
||||
@@ -161,7 +207,7 @@ export function createLobbyFeatures(options: LobbyOptions) {
|
||||
for (const entry of rows) {
|
||||
const row = document.createElement('tr');
|
||||
if (entry.playerId === profile?.playerId) row.className = 'self-ranking';
|
||||
for (const text of [entry.rank, entry.displayName + (entry.playerId === profile?.playerId ? ' (나)' : ''), entry.best, entry.wins]) {
|
||||
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);
|
||||
}
|
||||
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;
|
||||
latest = status; updateProfile(status.profile);
|
||||
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();
|
||||
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 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() {
|
||||
clearTimeout(timer);
|
||||
if (!active || latest?.match?.phase === 'finished') return;
|
||||
@@ -197,35 +259,55 @@ export function createLobbyFeatures(options: LobbyOptions) {
|
||||
}
|
||||
async function start() {
|
||||
if (busy || active) return;
|
||||
busy = true; rankedStart.disabled = true; generation++;
|
||||
busy = true; rankedStart.disabled = true; cancel.disabled = true;
|
||||
const currentGeneration = ++generation;
|
||||
options.onAvailabilityChange();
|
||||
showQueue('랭킹대전 매칭 중…');
|
||||
try { consume(await api.request<RankedStatus>('/api/ranked/queue', 'POST', {})); schedule(); }
|
||||
catch (error) { options.onHome(); options.onError(error); }
|
||||
finally { busy = false; options.onAvailabilityChange(); }
|
||||
try {
|
||||
const status = await api.request<RankedStatus>('/api/ranked/queue', 'POST', {});
|
||||
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() {
|
||||
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 {
|
||||
const status = latest?.match
|
||||
? await api.request<RankedStatus>('/api/ranked/leave', 'POST', { matchId: latest.match.matchId })
|
||||
: await api.request<RankedStatus>('/api/ranked/cancel', 'POST', {});
|
||||
consume(status);
|
||||
} catch (error) { options.onError(error); }
|
||||
finally { busy = false; cancel.disabled = false; options.onAvailabilityChange(); schedule(); }
|
||||
: await api.request<RankedStatus>('/api/ranked/cancel', 'POST', latest?.offer ? { matchId: latest.offer.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(); } }
|
||||
}
|
||||
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(); };
|
||||
cancel.onclick = () => { void leave(); };
|
||||
return {
|
||||
get active() { return active; },
|
||||
get busy() { return busy; },
|
||||
setReady(ready: boolean) { rankedStart.disabled = !ready || !profile || busy || active; appearanceOpen.disabled = !ready || !profile || busy || active; leaderboardOpen.disabled = !ready || !profile || busy || active; },
|
||||
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) {
|
||||
generation++; clearTimeout(timer);
|
||||
if (!restoreRanked) { updateProfile(await api.request<PlayerProfile>('/api/profile')); return false; }
|
||||
generation++; clearTimeout(timer); clearTimeout(confirmationTimer);
|
||||
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');
|
||||
updateProfile(status.profile); latest = status; active = status.status !== 'idle';
|
||||
await requireName();
|
||||
if (active) { consume(status); schedule(); }
|
||||
return active;
|
||||
},
|
||||
|
||||
+18
-7
@@ -114,7 +114,7 @@ function showMatch(state: LocalMatchState) {
|
||||
if (currentScreen !== 'result' || state.phase !== 'finished') setScreen('match');
|
||||
if (!view) view = createMatchView(el('match'), action => currentMatch?.ranked ? lobby!.submitAction(action) : controller?.submitAction(action) ?? Promise.resolve(false),
|
||||
() => { void perform(() => currentMatch?.ranked ? lobby!.rematch() : controller ? controller.rematch() : rematch(), 'rematch'); },
|
||||
() => { void perform(leave); }, () => setScreen('result'), footer.openRules, undefined,
|
||||
() => { void perform(leave); }, () => setScreen('result'), undefined,
|
||||
id => currentMatch?.ranked ? lobby!.sendEmote(id) : controller?.sendEmote(id) ?? false);
|
||||
view.update(state);
|
||||
if (state.finished?.saved && !state.ranked) stats(state.finished.stats);
|
||||
@@ -181,6 +181,9 @@ async function acceptRoom(room: PeerRoom) {
|
||||
waitingSocket.on('disconnect', () => { connection('연결 끊김', false, true); el('reconnect').hidden = false; });
|
||||
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() {
|
||||
if (!currentMatch?.finished?.saved) return;
|
||||
if (currentMatch.mode === 'bot') { storage.removeItem(GAME_CHECKPOINT_KEY); await launch('bot'); return; }
|
||||
@@ -257,7 +260,7 @@ async function boot() {
|
||||
const active = api.session.activeMatch;
|
||||
try {
|
||||
if (await lobby?.initialize(!active && !currentRoom)) { ready = true; return; }
|
||||
} catch (error) { errorNotice(error); }
|
||||
} catch (error) { errorNotice(error); return; }
|
||||
if (active) {
|
||||
if (active.mode === 'friend' && saved?.activeMatch.matchId !== active.matchId) {
|
||||
await reporter.submit({ ...active, outcome: 'aborted', reason: 'connection_failed' });
|
||||
@@ -292,7 +295,14 @@ async function boot() {
|
||||
} else if (currentRoom) {
|
||||
try { await acceptRoom(await api.request<PeerRoom>('/api/rooms/' + currentRoom.id)); }
|
||||
catch (error) { rememberRoom(); setScreen('home'); errorNotice(error); }
|
||||
} else setScreen(new URLSearchParams(location.search).has('room') ? 'join' : 'home');
|
||||
} 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;
|
||||
if (!storageAvailable) { notice('저장소를 사용할 수 없어 새로고침 시 기록을 복원할 수 없습니다.'); analytics.error('storage_unavailable', currentScreen || 'home'); }
|
||||
} catch (error) { errorNotice(error); }
|
||||
@@ -302,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('join-open').onclick = () => { setScreen('join'); el<HTMLInputElement>('room-code').focus(); };
|
||||
el('join-close').onclick = () => setScreen('home');
|
||||
el('join-form').onsubmit = e => { e.preventDefault(); void perform(async () => acceptRoom(await api.request<PeerRoom>('/api/rooms/join', 'POST', { code: el<HTMLInputElement>('room-code').value.trim().toUpperCase(), requestId: crypto.randomUUID() })), 'room_join'); };
|
||||
el('join-form').onsubmit = e => { e.preventDefault(); void perform(() => joinRoom(el<HTMLInputElement>('room-code').value), 'room_join'); };
|
||||
el('waiting-leave').onclick = () => { void perform(leave); };
|
||||
el('reconnect').onclick = () => {
|
||||
if (controller?.view().phase === 'finished') { void controller.retrySave(); return; }
|
||||
@@ -318,11 +328,12 @@ async function copy(link: boolean) {
|
||||
el('copy-code').onclick = () => { void copy(false); }; el('copy-link').onclick = () => { void copy(true); };
|
||||
el('rules-list').innerHTML = ACTION_IDS.map(id => '<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');
|
||||
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({
|
||||
api,
|
||||
onQueue: message => { setScreen('ranked-queue'); connection(message, true); },
|
||||
onMatch: state => showMatch(state),
|
||||
onQueue: message => { setScreen('ranked-queue'); connection(message, true); notice(); el('reconnect').hidden = true; },
|
||||
onMatch: state => { el('reconnect').hidden = true; showMatch(state); },
|
||||
onHome: () => { setScreen('home'); connection('접속됨', true); notice(); el('reconnect').hidden = true; },
|
||||
onAppearance: appearance => {
|
||||
el('waiting-fighter').innerHTML = fighterArt(0, false, appearance);
|
||||
|
||||
@@ -8,14 +8,16 @@ import { createFinishCutscene } from './finish-cutscene.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}">
|
||||
<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>`;
|
||||
|
||||
export function createMatchView(container: HTMLElement, onAction: (action: ActionId) => Promise<boolean>, onRematch: () => void, onLeave: () => void, onResult: () => void = () => {}, onRules: () => 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>
|
||||
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></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="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>
|
||||
@@ -62,6 +64,23 @@ export function createMatchView(container: HTMLElement, onAction: (action: Actio
|
||||
const el = <T extends HTMLElement = HTMLElement>(selector: string) => container.querySelector<T>(selector)!;
|
||||
el('.result-banner h2').tabIndex = -1;
|
||||
const buttons = Array.from(container.querySelectorAll<HTMLButtonElement>('[data-action]'));
|
||||
buttons.forEach(button => {
|
||||
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 () => {
|
||||
if (busy || !current || current.phase !== 'selecting' || current.ownAction || Date.now() + offset >= current.deadline) return;
|
||||
const action = button.dataset.action as ActionId;
|
||||
@@ -76,12 +95,7 @@ export function createMatchView(container: HTMLElement, onAction: (action: Actio
|
||||
if (request === submission) { busy = false; tick(); }
|
||||
}
|
||||
}));
|
||||
el('.leave').onclick = onLeave; el('.home-return').onclick = onLeave; el('.rematch').onclick = onRematch;
|
||||
el('.match-rules').onclick = () => {
|
||||
el<HTMLDetailsElement>('.match-menu').open = false;
|
||||
el('.match-menu summary').focus();
|
||||
onRules();
|
||||
};
|
||||
el('.home-return').onclick = onLeave; el('.rematch').onclick = onRematch;
|
||||
function tick() {
|
||||
if (!current) return;
|
||||
const now = Date.now() + offset, s = current;
|
||||
@@ -196,6 +210,7 @@ export function createMatchView(container: HTMLElement, onAction: (action: Actio
|
||||
appearanceSignatures[i] = signature;
|
||||
}
|
||||
node.classList.toggle('self', i === state.selfIndex);
|
||||
node.querySelector('.fighter-position')!.textContent = i === state.selfIndex ? '▼ 나' : '▼ 상대';
|
||||
node.setAttribute('role', 'group');
|
||||
node.setAttribute('aria-label', i === state.selfIndex ? '내 캐릭터' : state.mode === 'bot' ? '봇 캐릭터' : '상대 캐릭터');
|
||||
});
|
||||
@@ -206,6 +221,6 @@ export function createMatchView(container: HTMLElement, onAction: (action: Actio
|
||||
}
|
||||
cutscene.update(state);
|
||||
tick();
|
||||
}, destroy() { submission++; clearInterval(interval); cutscene.destroy(); renderer.destroy(); sound.stop(); container.onpointerdown = null; container.onkeydown = null; container.innerHTML = ''; current = undefined; },
|
||||
}, destroy() { submission++; clearInterval(interval); document.removeEventListener('keydown', onKeyDown); cutscene.destroy(); renderer.destroy(); sound.stop(); container.onpointerdown = null; container.onkeydown = null; container.innerHTML = ''; current = undefined; },
|
||||
};
|
||||
}
|
||||
|
||||
+9
-28
@@ -15,7 +15,7 @@ body:is([data-screen=match], [data-screen=result]) main { padding: 0; min-height
|
||||
#match {
|
||||
--stage-top: calc(120px + env(safe-area-inset-top));
|
||||
--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);
|
||||
--self-position: 26.63%;
|
||||
position: relative; isolation: isolate; flex: 1; width: 100%; max-width: none;
|
||||
@@ -47,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. */
|
||||
#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 {
|
||||
width: var(--fighter-size);
|
||||
max-width: none; margin: 0; left: 50%; translate: -50% 0;
|
||||
@@ -74,29 +76,6 @@ body:is([data-screen=match], [data-screen=result]) main { padding: 0; min-height
|
||||
#match .time-track span { border-radius: 0; background: #a85039; }
|
||||
#match[data-locked=true] .time-track span { background: #557b85; }
|
||||
#match .timing[data-urgent=true] .time-track span { background: #c04128; }
|
||||
#match .match-menu {
|
||||
pointer-events: auto;
|
||||
position: absolute; top: calc(25px + env(safe-area-inset-top));
|
||||
right: max(var(--edge), env(safe-area-inset-right));
|
||||
}
|
||||
#match .match-menu summary {
|
||||
display: grid; place-items: center; list-style: none; cursor: pointer;
|
||||
min-width: 52px; min-height: 44px; padding: 8px 12px;
|
||||
border: 1px solid #52615655; border-radius: 3px; background: #f4f0e3;
|
||||
font-size: 12px; font-weight: 700;
|
||||
}
|
||||
#match .match-menu summary::-webkit-details-marker { display: none; }
|
||||
#match .match-menu summary:focus-visible { outline: 3px solid #266acc; outline-offset: 4px; }
|
||||
#match .match-menu[open] summary { background: #303c38; color: #fff9e9; }
|
||||
#match .match-menu-items {
|
||||
position: absolute; top: calc(100% + 8px); right: 0; width: 132px;
|
||||
padding: 6px; background: #f4f0e3; border: 1px solid #697568;
|
||||
box-shadow: 3px 4px 0 #303c3820;
|
||||
}
|
||||
#match .match-menu-items button {
|
||||
display: block; width: 100%; min-height: 44px; border: 0; border-radius: 0;
|
||||
color: #303c38; text-align: left;
|
||||
}
|
||||
#match .duel-cue, #match[data-phase=countdown] .duel-cue { top: 43%; }
|
||||
#match:not([data-phase=countdown]) .versus { display: none; }
|
||||
#match[data-phase=countdown] .countdown-number { border-radius: 3px; }
|
||||
@@ -124,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;
|
||||
}
|
||||
#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 button:hover:not(:disabled) { background: #fff9e9; border-color: var(--action-color); transform: translateY(-2px); }
|
||||
#match .action-buttons button:active:not(:disabled) { transform: translateY(1px); }
|
||||
@@ -170,7 +153,7 @@ body:is([data-screen=match], [data-screen=result]) #reconnect {
|
||||
--edge: 16px; --stage-bottom: 16px;
|
||||
--self-position: 25.09%;
|
||||
--stage-top: calc(100px + env(safe-area-inset-top));
|
||||
--fighter-size: min(60vw, calc((100cqh - 38px) * .866667), 360px);
|
||||
--fighter-size: min(48vw, calc((100cqh - 62px) * .866667), 288px);
|
||||
}
|
||||
#match[data-self="1"] { --self-position: 74.91%; }
|
||||
#match[data-phase=finished] { --stage-top: calc(210px + env(safe-area-inset-top)); }
|
||||
@@ -178,7 +161,6 @@ body:is([data-screen=match], [data-screen=result]) #reconnect {
|
||||
#match .fighter { width: 47%; }
|
||||
#match .match-title { top: calc(24px + env(safe-area-inset-top)); left: var(--edge); transform: none; }
|
||||
#match .turn-heading { font-size: 16px; }
|
||||
#match .match-menu { top: calc(12px + env(safe-area-inset-top)); }
|
||||
#match .energy { gap: 3px; }
|
||||
#match .energy-count { font-size: 25px; }
|
||||
#match .energy > svg { width: 18px; height: 18px; }
|
||||
@@ -211,13 +193,12 @@ body:is([data-screen=match], [data-screen=result]) #reconnect {
|
||||
--edge: max(16px, env(safe-area-inset-left)); --stage-bottom: 8px;
|
||||
--stage-top: calc(78px + env(safe-area-inset-top));
|
||||
--self-position: 28.34%;
|
||||
--fighter-size: min(26vw, calc((100cqh - 38px) * .866667), 260px);
|
||||
--fighter-size: min(26vw, calc((100cqh - 62px) * .866667), 260px);
|
||||
}
|
||||
#match[data-self="1"] { --self-position: 71.66%; }
|
||||
#match[data-phase=finished] { --stage-top: calc(96px + env(safe-area-inset-top)); }
|
||||
#match .arena { padding-inline: 12vw; }
|
||||
#match .match-title { top: calc(12px + env(safe-area-inset-top)); left: 50%; transform: translateX(-50%); }
|
||||
#match .match-menu { top: calc(10px + env(safe-area-inset-top)); right: max(12px, env(safe-area-inset-right)); }
|
||||
#match .timing { top: calc(38px + env(safe-area-inset-top)); width: 120px; min-height: 33px; flex-direction: row; padding-bottom: 8px; }
|
||||
#match .player-controls { width: calc(50% - 24px); margin-bottom: max(8px, env(safe-area-inset-bottom)); }
|
||||
#match .energy { padding-bottom: 4px; }
|
||||
|
||||
@@ -15,7 +15,7 @@ export function createPresence(counter: HTMLElement) {
|
||||
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}명`;
|
||||
if (Number.isSafeInteger(count) && (count as number) >= 0) counter.textContent = `현재 ${count}명 접속 중`;
|
||||
});
|
||||
socket.on('connect', () => clearTimeout(retry));
|
||||
socket.on('disconnect', unknown);
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 392 KiB |
+17
-13
@@ -1,4 +1,15 @@
|
||||
/* 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) {
|
||||
@@ -34,8 +45,7 @@ body.player-app #match .action-buttons strong {
|
||||
}
|
||||
|
||||
body.player-app button,
|
||||
body.player-app #match button,
|
||||
body.player-app #match .match-menu summary {
|
||||
body.player-app #match button {
|
||||
border: 2px solid var(--ink);
|
||||
border-radius: 0;
|
||||
background: var(--ink);
|
||||
@@ -45,8 +55,7 @@ body.player-app #match .match-menu summary {
|
||||
}
|
||||
|
||||
body.player-app button.primary,
|
||||
body.player-app #match button.primary,
|
||||
body.player-app #match .action-buttons button[data-action=charge] {
|
||||
body.player-app #match button.primary {
|
||||
border-radius: 0;
|
||||
background: var(--ui-orange);
|
||||
color: var(--ink);
|
||||
@@ -54,8 +63,7 @@ body.player-app #match .action-buttons button[data-action=charge] {
|
||||
}
|
||||
|
||||
body.player-app button:hover:not(:disabled),
|
||||
body.player-app #match button:hover:not(:disabled),
|
||||
body.player-app #match .match-menu summary:hover {
|
||||
body.player-app #match button:hover:not(:disabled) {
|
||||
background: #ffbd43;
|
||||
border-color: var(--ink);
|
||||
color: var(--ink);
|
||||
@@ -63,8 +71,7 @@ body.player-app #match .match-menu summary:hover {
|
||||
}
|
||||
|
||||
body.player-app button:active:not(:disabled),
|
||||
body.player-app #match button:active:not(:disabled),
|
||||
body.player-app #match .match-menu summary:active {
|
||||
body.player-app #match button:active:not(:disabled) {
|
||||
border-width: 2px;
|
||||
box-shadow: none;
|
||||
transform: translate(3px, 3px);
|
||||
@@ -106,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 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[data-action=charge]:disabled:not(.chosen) { background: #e4e4e4; color: #666; }
|
||||
body.player-app #match .action-buttons small { color: inherit; font-family: var(--ui-body); font-weight: 400; }
|
||||
body.player-app #match .action-buttons .action-symbol { color: inherit; opacity: 1; filter: none; }
|
||||
body.player-app #match .energy > svg { color: var(--ink); }
|
||||
body.player-app #match .chosen-mark { background: var(--ink); border-color: #fff; color: #fff; border-radius: 0; }
|
||||
body.player-app #match .match-menu-items { background: #fff; border: 2px solid var(--ink); border-radius: 0; box-shadow: 4px 4px 0 #24252640; padding: 8px; }
|
||||
body.player-app #match .match-menu-items button + button { margin-top: 8px; }
|
||||
|
||||
body.player-app dialog:not(.finish-cutscene) {
|
||||
padding: 24px;
|
||||
@@ -136,5 +140,5 @@ body.player-app #contact-content { overflow-wrap: anywhere; }
|
||||
}
|
||||
|
||||
@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; }
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { PlayerDocument } from './stats-store.js';
|
||||
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 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 sampleStart = new Date(Math.max(+start, +now - PRESENCE_RETENTION_MS));
|
||||
const samples = await db.collection('analytics_presence_samples').aggregate([
|
||||
{ $match: { occurredAt: { $gte: start, $lt: end }, environment: 'production', source: 'activity_reports' } },
|
||||
{ $group: { _id: null, count: { $sum: 1 }, active: { $max: '$active' } } }, { $project: { _id: 0 } },
|
||||
{ $match: { occurredAt: { $gte: sampleStart, $lt: end }, environment: 'production', source: 'activity_reports' } },
|
||||
{ $group: { _id: null, count: { $sum: { $ifNull: ['$sampleCount', 1] } }, active: { $max: '$active' } } }, { $project: { _id: 0 } },
|
||||
]).toArray();
|
||||
const metadata = await db.collection('analytics_metadata').findOne({ _id: 'production' as any });
|
||||
const quality = await db.collection('analytics_presence_samples').aggregate([
|
||||
{ $match: { occurredAt: { $gte: start, $lt: end }, environment: 'production', source: 'activity_reports' } },
|
||||
{ $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: null, failures: { $sum: '$failures' }, dropped: { $sum: '$dropped' }, rateLimited: { $sum: '$rateLimited' } } }, { $project: { _id: 0 } },
|
||||
]).toArray();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { MongoServerError, type Db } from 'mongodb';
|
||||
import { ACTIVE_WINDOW_MS, RETENTION_MS, VISIT_SESSION_MS } from '../../constants/analytics.js';
|
||||
import { 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 { PlayerDocument } from './stats-store.js';
|
||||
import { kstDay } from './analytics-report.js';
|
||||
@@ -8,7 +8,7 @@ export interface AnalyticsStore {
|
||||
db: Db;
|
||||
touch(playerId: string, now: Date, visible: boolean, metadata: VisitMetadata, metadataFinal?: boolean): Promise<VisitSession>;
|
||||
insert(event: AnalyticsEvent): Promise<void>;
|
||||
sample(now: Date, presence: Presence, environment: string, quality?: { restartedAt: Date; failures: number; dropped: number; rateLimited: number }): Promise<void>;
|
||||
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>;
|
||||
}
|
||||
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 });
|
||||
for (const field of ['eventType', 'playerId', 'sessionId']) await events.createIndex({ [field]: 1, 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 });
|
||||
const cleanup = async (now: Date) => { await players.updateMany({ 'visit.expiresAt': { $lte: now } }, { $unset: { visit: '' } }); };
|
||||
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 });
|
||||
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
@@ -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 type { AnalyticsEvent, Presence, VisitMetadata, VisitSession } from '../shared/analytics-types.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;
|
||||
httpRequests = 0;
|
||||
lastCleanup = 0;
|
||||
private sampleWindowStartedAt: number;
|
||||
private pendingSample?: { at: number; presence: Presence; 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 };
|
||||
constructor(private store: AnalyticsStore, private clock: Clock, private environment = 'production') {
|
||||
this.sampleWindowStartedAt = clock.now();
|
||||
this.quality.restartedAt = new Date(clock.now()); this.scheduleSample();
|
||||
}
|
||||
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', '오류 코드를 확인하세요.');
|
||||
const gameSummary = data.eventType === 'game_started' || data.eventType === 'game_finished';
|
||||
if (gameSummary && (!validId(data.matchId) || !['bot', 'friend'].includes(String(data.mode)))) reject('INVALID_ANALYTICS_EVENT', '대전을 확인하세요.');
|
||||
if (data.eventType === 'game_finished' && (!['win', 'loss', 'draw', 'aborted'].includes(String(data.outcome)) || !['hit', 'timeout', 'double_timeout', 'forfeit', 'connection_failed', 'protocol_error'].includes(String(data.reason)))) reject('INVALID_ANALYTICS_EVENT', '결과를 확인하세요.');
|
||||
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);
|
||||
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}`;
|
||||
@@ -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 };
|
||||
}
|
||||
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() {
|
||||
this.sampleTimer = this.clock.setTimeout(() => {
|
||||
const at = this.clock.now(), presence = this.live(), quality = { restartedAt: this.quality.restartedAt, failures: this.quality.failures, dropped: this.quality.dropped, rateLimited: this.quality.rateLimited };
|
||||
void this.enqueue(`sample:${at}`, at, () => this.store.sample(new Date(at), presence, this.environment, quality)).catch(() => {});
|
||||
const at = this.clock.now(), presence = this.live();
|
||||
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) {
|
||||
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);
|
||||
for (const j of this.queue) j.waiters.forEach(w => w.reject());
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import { registerRankedApi } from './ranked-api.js';
|
||||
import { createGlobalSettingsStore } from './global-settings.js';
|
||||
import { gameRoute } from './game-api.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 async function createGameServer(options: ServerOptions): Promise<FastifyInstance> {
|
||||
const store = await createStatsStore(options);
|
||||
@@ -33,6 +34,14 @@ export async function createGameServer(options: ServerOptions): Promise<FastifyI
|
||||
? { revision: current.revision } : { revision: current.revision, settings: current };
|
||||
});
|
||||
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 ranked = await createRankedService(store.db, clock, options.environment ?? 'production', () => logSafeError(app.log, 'ranked_background', undefined));
|
||||
@@ -58,6 +67,7 @@ export async function createGameServer(options: ServerOptions): Promise<FastifyI
|
||||
}
|
||||
registerAdmin(app, store, analytics, clock, { user: options.adminUser, password: options.adminPassword }, directory, settings);
|
||||
app.addHook('preClose', async () => {
|
||||
cleanupClosed = true; clock.clearTimeout(cleanupTimer); await cleanupTask;
|
||||
await ranked.close();
|
||||
await analytics.close();
|
||||
await new Promise<void>(done => io.close(() => done()));
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { FighterAppearance } from '../shared/cosmetics.js';
|
||||
import type { Pair } from '../shared/types.js';
|
||||
import { createGlobalSettingsStore } from './global-settings.js';
|
||||
import { DEFAULT_GLOBAL_SETTINGS } from '../../constants/global-settings.js';
|
||||
import { MATCH_PRESENCE_GRACE_MS } from '../../constants/presence.js';
|
||||
|
||||
export class ApiError extends Error {
|
||||
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 {
|
||||
_id: string; tokenHash: string; playerId?: string; environment: string; createdAt: Date; updatedAt: Date; expiresAt: Date;
|
||||
stats: PlayerStats; lastAppliedSeq: number;
|
||||
presenceUntil?: Date;
|
||||
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 async function createGameSessionStore(db: Db, now: () => number = Date.now, environment = 'production') {
|
||||
const sessions = db.collection<SessionDocument>('game_sessions');
|
||||
const recoverUntil = now() + MATCH_PRESENCE_GRACE_MS;
|
||||
await sessions.createIndex({ expiresAt: 1 }, { expireAfterSeconds: 0 });
|
||||
await sessions.createIndex({ playerId: 1, updatedAt: -1 });
|
||||
await sessions.createIndex({ presenceUntil: 1 });
|
||||
const authFilter = (auth: GameSessionAuth) => {
|
||||
if (!auth || !validId(auth.gameSessionId) || typeof auth.token !== 'string' || !/^[A-Za-z0-9_-]{43}$/.test(auth.token)) throw new ApiError(401, 'INVALID_SESSION');
|
||||
return { _id: auth.gameSessionId, tokenHash: hash(auth.token), expiresAt: { $gt: new Date(now()) } };
|
||||
@@ -61,6 +65,45 @@ export async function createGameSessionStore(db: Db, now: () => number = Date.no
|
||||
return publicState(await read({ gameSessionId, token }), { gameSessionId, token });
|
||||
},
|
||||
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> {
|
||||
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);
|
||||
@@ -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,
|
||||
...(request.roomId ? { roomId: request.roomId } : {}), appearances, settings, registerRequestId: request.requestId };
|
||||
const doc = await sessions.findOneAndUpdate({ ...authFilter(auth), lastAppliedSeq: request.expectedPreviousSeq, activeMatch: { $exists: false } }, {
|
||||
$set: { activeMatch: match, updatedAt: new Date(now()), expiresAt: new Date(now() + GAME_SESSION_TTL_MS) },
|
||||
$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' });
|
||||
if (doc) return active(doc)!;
|
||||
const current = await read(auth);
|
||||
@@ -116,6 +159,7 @@ export async function createGameSessionStore(db: Db, now: () => number = Date.no
|
||||
const current = await read(auth);
|
||||
if (input.seq < current.lastAppliedSeq) return { status: 'stale', stats: current.stats };
|
||||
if (input.seq === current.lastAppliedSeq && current.lastResult?.matchId === input.matchId) {
|
||||
if (current.lastResult.abandoned) return { status: 'stale', stats: current.stats };
|
||||
if (current.lastResult.fingerprint !== fingerprint) throw new ApiError(409, 'RESULT_CONFLICT');
|
||||
return { status: 'duplicate', stats: current.stats };
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ export function createGlobalSettingsStore(db: Db) {
|
||||
return {
|
||||
async initialize() {
|
||||
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 } });
|
||||
}
|
||||
},
|
||||
@@ -24,7 +24,7 @@ export function createGlobalSettingsStore(db: Db) {
|
||||
async update(input: unknown) {
|
||||
if (!isGlobalSettings(input) || Object.keys(input).some(key => !Object.hasOwn(DEFAULT_GLOBAL_SETTINGS, key))) throw new ApiError(400, 'INVALID_SETTINGS');
|
||||
const doc = await collection.findOneAndUpdate({ _id: 'global', revision: input.revision }, {
|
||||
$set: { turnDurationMs: input.turnDurationMs, finishCutsceneSpeed: input.finishCutsceneSpeed, matchingDelayMs: input.matchingDelayMs, matchSuccessDelayMs: input.matchSuccessDelayMs, matchPreparationDelayMs: input.matchPreparationDelayMs }, $inc: { revision: 1 },
|
||||
$set: { turnDurationMs: input.turnDurationMs, finishCutsceneSpeed: input.finishCutsceneSpeed, matchingDelayMs: input.matchingDelayMs, matchSuccessDelayMs: input.matchSuccessDelayMs, matchPreparationDelayMs: input.matchPreparationDelayMs, rankedAcceptTimeoutMs: input.rankedAcceptTimeoutMs }, $inc: { revision: 1 },
|
||||
}, { returnDocument: 'after' });
|
||||
if (!doc) throw new ApiError(409, 'SETTINGS_REVISION_CONFLICT');
|
||||
const { _id, ...settings } = doc;
|
||||
|
||||
@@ -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.
|
||||
export function logSafeError(logger: Pick<FastifyBaseLogger, 'error'>, operation: ErrorOperation, error: unknown, request?: FastifyRequest) {
|
||||
const name = error instanceof Error ? error.name : undefined;
|
||||
|
||||
@@ -12,7 +12,7 @@ export function registerPresence(io: Server, sessions: GameSessionStore) {
|
||||
}
|
||||
namespace.use((socket, next) => {
|
||||
const { gameSessionId, token } = socket.handshake.auth;
|
||||
void sessions.get({ gameSessionId, token }).then(session => {
|
||||
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')));
|
||||
@@ -25,7 +25,7 @@ export function registerPresence(io: Server, sessions: GameSessionStore) {
|
||||
if (checking) return;
|
||||
checking = true;
|
||||
const { gameSessionId, token } = socket.handshake.auth;
|
||||
void sessions.get({ gameSessionId, token }).catch(error => {
|
||||
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; });
|
||||
|
||||
@@ -14,15 +14,23 @@ export function registerRankedApi(app: FastifyInstance, sessions: GameSessionSto
|
||||
gameRoute(app, 'GET', '/api/profile', async request => service.profile((await identity(request)).playerId));
|
||||
gameRoute(app, 'POST', '/api/profile', async request => {
|
||||
const { playerId } = await identity(request);
|
||||
return service.updateProfile(playerId, request.body as { appearance?: FighterAppearance });
|
||||
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));
|
||||
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 => {
|
||||
const { playerId, gameSessionId } = await identity(request);
|
||||
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 => {
|
||||
const { playerId, gameSessionId } = await identity(request);
|
||||
const body = request.body as { matchId: string; turnId: number; action: ActionId } | undefined;
|
||||
|
||||
+107
-41
@@ -1,8 +1,7 @@
|
||||
import { randomInt, randomUUID } from 'node:crypto';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { Db } from 'mongodb';
|
||||
import { COSMETIC_COLORS, EQUIPMENT_IDS, EYEWEAR_IDS, HAIR_STYLES, SKIN_TONES } from '../../constants/cosmetics.js';
|
||||
import {
|
||||
PROFILE_NAME_NOUNS, PROFILE_NAME_PREFIXES, PROFILE_NAME_SUFFIX_MAX, PROFILE_NAME_SUFFIX_MIN, PROFILE_NAME_VERSION,
|
||||
RANKED_FINISHED_TTL_MS, RANKED_LEADERBOARD_LIMIT, RANKED_QUEUE_TTL_MS, RANKED_SAVE_RETRY_MS,
|
||||
RANKED_STATS_CACHE_LIMIT, RANKED_STATS_CACHE_TTL_MS, RANKED_LEADERBOARD_CACHE_LIMIT, RANKED_LEADERBOARD_CACHE_TTL_MS,
|
||||
} from '../../constants/ranked.js';
|
||||
@@ -15,18 +14,19 @@ import type { ActionId, ModeStats, Pair } from '../shared/types.js';
|
||||
import { systemClock, type Clock } from './clock.js';
|
||||
import { ApiError, validId } from './game-session-store.js';
|
||||
import { createGlobalSettingsStore } from './global-settings.js';
|
||||
import { matchIntroductionDuration } from '../shared/match-introduction.js';
|
||||
import { createEmotes } from '../shared/emotes.js';
|
||||
import { profileNameError } from '../shared/profile-name.js';
|
||||
|
||||
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 {
|
||||
_id: string; playerIds: Pair<string>; appearances: Pair<FighterAppearance>; winnerIndex: 0 | 1 | null;
|
||||
reason: LocalEndReason; order: number; completedAt: Date; environment: string;
|
||||
}
|
||||
interface QueueEntry { playerId: string; gameSessionId: string; expiresAt: number }
|
||||
interface QueueEntry { playerId: string; gameSessionId: string; expiresAt: number; previousMatchId?: string }
|
||||
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;
|
||||
@@ -34,21 +34,15 @@ interface RankedMatch {
|
||||
terminal?: TerminalMatch; saved: boolean; finishedExpiresAt?: Pair<number>;
|
||||
}
|
||||
const emptyStats = (): ModeStats => ({ wins: 0, losses: 0, draws: 0, current: 0, best: 0 });
|
||||
function generatedName(): string {
|
||||
return `${PROFILE_NAME_PREFIXES[randomInt(PROFILE_NAME_PREFIXES.length)]}${PROFILE_NAME_NOUNS[randomInt(PROFILE_NAME_NOUNS.length)]}-${randomInt(PROFILE_NAME_SUFFIX_MIN, PROFILE_NAME_SUFFIX_MAX)}`;
|
||||
}
|
||||
|
||||
// One service owns the queue and all match clocks in the established single-process server.
|
||||
export async function createRankedService(db: Db, clock: Clock = systemClock, environment = 'production', onError?: (code: string) => void) {
|
||||
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');
|
||||
// Finish migrating every legacy name before any profile or leaderboard can be served.
|
||||
// The version guard makes interrupted migrations/restarts preserve assigned names.
|
||||
for await (const profile of profiles.find({ nameVersion: { $ne: PROFILE_NAME_VERSION } })) {
|
||||
await profiles.updateOne({ _id: profile._id, nameVersion: { $ne: PROFILE_NAME_VERSION } }, { $set: {
|
||||
displayName: generatedName(), nameVersion: PROFILE_NAME_VERSION, updatedAt: new Date(clock.now()),
|
||||
} });
|
||||
}
|
||||
await results.createIndex({ playerIds: 1, order: 1 });
|
||||
let resultOrder = (await results.find().sort({ order: -1 }).limit(1).next())?.order ?? 0;
|
||||
const queue = new Map<string, QueueEntry>();
|
||||
@@ -60,6 +54,14 @@ export async function createRankedService(db: Db, clock: Clock = systemClock, en
|
||||
selves: Map<string, LeaderboardEntry | undefined>; complete: boolean } | undefined;
|
||||
let leaderboardRead: Promise<LeaderboardEntry[]> | undefined;
|
||||
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> {
|
||||
const next = tail.then(() => {
|
||||
if (closed) throw new ApiError(503, 'RANKED_CLOSED');
|
||||
@@ -128,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) {
|
||||
const stats = totals.get(id) ?? emptyStats(); addResult(stats, result, id); totals.set(id, stats);
|
||||
}
|
||||
const entries = (await profiles.find({ _id: { $in: [...totals.keys()] } }).toArray()).map(profile => ({ playerId: profile._id,
|
||||
displayName: profile.displayName, wins: totals.get(profile._id)!.wins, best: totals.get(profile._id)!.best,
|
||||
})).sort((a, b) => b.best - a.best || b.wins - a.wins || (a.playerId < b.playerId ? -1 : a.playerId > b.playerId ? 1 : 0))
|
||||
const entries = (await Promise.all((await profiles.find({ _id: { $in: [...totals.keys()] } }).toArray()).map(async profile => ({ playerId: profile._id,
|
||||
nameTag: await ensureNameTag(profile),
|
||||
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 }));
|
||||
// Return an overlapping snapshot without caching it; retrying here can starve
|
||||
// under steady saves. The next request refreshes, independent of gameplay.
|
||||
@@ -144,12 +147,13 @@ export async function createRankedService(db: Db, clock: Clock = systemClock, en
|
||||
if (!validId(playerId)) throw new ApiError(400, 'INVALID_PROFILE');
|
||||
const now = new Date(clock.now());
|
||||
await profiles.updateOne({ _id: playerId }, { $setOnInsert: {
|
||||
displayName: generatedName(), nameVersion: PROFILE_NAME_VERSION,
|
||||
displayName: '',
|
||||
appearance: { ...DEFAULT_APPEARANCE }, environment, createdAt: now, updatedAt: now,
|
||||
} }, { upsert: true });
|
||||
const doc = (await profiles.findOne({ _id: playerId }))!;
|
||||
const nameTag = await ensureNameTag(doc);
|
||||
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) };
|
||||
}
|
||||
async function persist(match: RankedMatch) {
|
||||
@@ -181,7 +185,7 @@ export async function createRankedService(db: Db, clock: Clock = systemClock, en
|
||||
const state = match.engine.view();
|
||||
if (state.aborted) return;
|
||||
if (state.phase === 'finished' && match.saved && match.departed.size === 2) return;
|
||||
const at = state.phase === 'countdown' ? state.startsAt : state.phase === 'selecting' ? state.deadline
|
||||
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
|
||||
? Math.min(...match.playerIds.map((id, slot) => match.departed.has(id) ? Infinity : match.finishedExpiresAt![slot]))
|
||||
: clock.now() + RANKED_SAVE_RETRY_MS;
|
||||
@@ -195,6 +199,11 @@ export async function createRankedService(db: Db, clock: Clock = systemClock, en
|
||||
}
|
||||
async function advance(match: RankedMatch) {
|
||||
try {
|
||||
if (!matches.has(match)) return;
|
||||
if (match.confirmation) {
|
||||
if (clock.now() >= match.confirmation.expiresAt) await cancelConfirmation(match);
|
||||
return;
|
||||
}
|
||||
let state = match.engine.view();
|
||||
if (state.phase === 'countdown' && clock.now() >= state.startsAt) match.engine.beginTurn(state.startsAt);
|
||||
if (state.phase === 'revealing' && clock.now() >= state.reveal!.revealEndsAt) {
|
||||
@@ -211,11 +220,15 @@ export async function createRankedService(db: Db, clock: Clock = systemClock, en
|
||||
} finally { schedule(match); }
|
||||
}
|
||||
async function statusView(playerId: string): Promise<RankedStatus> {
|
||||
const match = byPlayer.get(playerId);
|
||||
let match = byPlayer.get(playerId);
|
||||
if (match) await advance(match);
|
||||
match = byPlayer.get(playerId);
|
||||
const profile = await readProfile(playerId);
|
||||
if (!match) return { status: queue.has(playerId) ? 'queued' : 'idle', profile };
|
||||
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();
|
||||
if (state.finished) {
|
||||
state.finished.stats.friend = structuredClone(profile.stats);
|
||||
@@ -238,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');
|
||||
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 {
|
||||
async profile(playerId: string) {
|
||||
if (closed) throw new ApiError(503, 'RANKED_CLOSED');
|
||||
return readProfile(playerId);
|
||||
},
|
||||
updateProfile(playerId: string, input: { appearance?: FighterAppearance }): Promise<PlayerProfile> {
|
||||
updateProfile(playerId: string, input: { displayName?: string; appearance?: FighterAppearance }): Promise<PlayerProfile> {
|
||||
return serial(async () => {
|
||||
if (!input || typeof input !== 'object' || Array.isArray(input)) throw new ApiError(400, 'INVALID_PROFILE');
|
||||
if (Object.hasOwn(input, 'displayName')) throw new ApiError(403, 'PROFILE_NAME_READONLY');
|
||||
if (Object.hasOwn(input, 'displayName')) {
|
||||
const error = profileNameError(input.displayName);
|
||||
if (error) throw new ApiError(400, error);
|
||||
}
|
||||
const profile = await readProfile(playerId);
|
||||
const update: Partial<ProfileDocument> = {};
|
||||
if (Object.hasOwn(input, 'displayName')) update.displayName = input.displayName!.normalize('NFKC').trim();
|
||||
if (input.appearance !== undefined) {
|
||||
const appearance = input.appearance;
|
||||
if (!appearance || typeof appearance !== 'object' || Array.isArray(appearance)
|
||||
@@ -269,6 +320,7 @@ export async function createRankedService(db: Db, clock: Clock = systemClock, en
|
||||
update.appearance = next;
|
||||
}
|
||||
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);
|
||||
});
|
||||
},
|
||||
@@ -286,7 +338,7 @@ export async function createRankedService(db: Db, clock: Clock = systemClock, en
|
||||
const pending = leaderboardRead ??= scanLeaderboard();
|
||||
try {
|
||||
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);
|
||||
if (leaderboardCache.selves.size > RANKED_LEADERBOARD_CACHE_LIMIT) {
|
||||
leaderboardCache.selves.delete(leaderboardCache.selves.keys().next().value!);
|
||||
@@ -319,28 +371,38 @@ export async function createRankedService(db: Db, clock: Clock = systemClock, en
|
||||
if (byPlayer.has(playerId)) return statusView(playerId);
|
||||
await freeForRanked(playerId); await readProfile(playerId);
|
||||
queue.set(playerId, { playerId, gameSessionId, expiresAt: clock.now() + RANKED_QUEUE_TTL_MS });
|
||||
for (const candidate of queue.values()) {
|
||||
if (candidate.playerId === playerId) continue;
|
||||
try { await identity(candidate.playerId, candidate.gameSessionId); await freeForRanked(candidate.playerId); }
|
||||
catch (error) {
|
||||
if (!(error instanceof ApiError)) throw error;
|
||||
queue.delete(candidate.playerId); continue;
|
||||
}
|
||||
const first = await readProfile(candidate.playerId), second = await readProfile(playerId);
|
||||
const id = randomUUID(), engine = createMatchEngine({ matchId: id, mode: 'friend', selfIndex: 0, clock, settings: await createGlobalSettingsStore(db).get() });
|
||||
const match: RankedMatch = { id, engine, emotes: createEmotes(), emoteVersion: 0, playerIds: [candidate.playerId, playerId],
|
||||
appearances: [first.appearance, second.appearance], actions: [null, null], departed: new Set(), saved: false };
|
||||
engine.start(clock.now() + matchIntroductionDuration(engine.view().settings, true)); matches.add(match);
|
||||
for (const id of match.playerIds) { queue.delete(id); byPlayer.set(id, match); }
|
||||
schedule(match); break;
|
||||
await pairQueued();
|
||||
return statusView(playerId);
|
||||
});
|
||||
},
|
||||
accept(playerId: string, gameSessionId: string, matchId: string): Promise<RankedStatus> {
|
||||
return serial(async () => {
|
||||
await identity(playerId, gameSessionId);
|
||||
if (!validId(matchId)) throw new ApiError(400, 'INVALID_REQUEST');
|
||||
const match = byPlayer.get(playerId);
|
||||
if (!match || match.id !== matchId) return statusView(playerId);
|
||||
await advance(match);
|
||||
if (byPlayer.get(playerId) !== match || !match.confirmation) return statusView(playerId);
|
||||
const slot = match.playerIds[0] === playerId ? 0 : 1;
|
||||
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);
|
||||
});
|
||||
},
|
||||
cancel(playerId: string, gameSessionId: string): Promise<RankedStatus> {
|
||||
cancel(playerId: string, gameSessionId: string, matchId?: string): Promise<RankedStatus> {
|
||||
return serial(async () => {
|
||||
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);
|
||||
});
|
||||
},
|
||||
@@ -348,6 +410,7 @@ export async function createRankedService(db: Db, clock: Clock = systemClock, en
|
||||
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');
|
||||
@@ -359,6 +422,7 @@ export async function createRankedService(db: Db, clock: Clock = systemClock, en
|
||||
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');
|
||||
const state = match.engine.view(), slot = match.playerIds[0] === playerId ? 0 : 1;
|
||||
if (!Number.isSafeInteger(turnId) || state.phase !== 'selecting' || turnId !== state.turnId
|
||||
|| clock.now() < state.startsAt || clock.now() >= state.deadline || !isLegalAction(action, state.energy[slot])) throw new ApiError(400, 'INVALID_ACTION');
|
||||
@@ -373,6 +437,8 @@ export async function createRankedService(db: Db, clock: Clock = systemClock, en
|
||||
if (!validId(matchId)) throw new ApiError(400, 'INVALID_REQUEST');
|
||||
if (!byPlayer.has(playerId) && await results.findOne({ _id: matchId, playerIds: playerId })) return statusView(playerId);
|
||||
const match = memberMatch(playerId, matchId); await advance(match);
|
||||
if (byPlayer.get(playerId) !== match) return statusView(playerId);
|
||||
if (match.confirmation) { await cancelConfirmation(match, playerId); return statusView(playerId); }
|
||||
if (match.engine.view().phase !== 'finished') {
|
||||
match.engine.finish(match.playerIds[0] === playerId ? 1 : 0, 'forfeit');
|
||||
await advance(match);
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { GLOBAL_SETTINGS_LIMITS } from '../../constants/global-settings.js';
|
||||
|
||||
export interface GlobalSettings { revision: number; turnDurationMs: number; finishCutsceneSpeed: number; matchingDelayMs: number; matchSuccessDelayMs: number; matchPreparationDelayMs: number }
|
||||
export interface GlobalSettings { revision: number; turnDurationMs: number; finishCutsceneSpeed: number; matchingDelayMs: number; matchSuccessDelayMs: number; matchPreparationDelayMs: number; rankedAcceptTimeoutMs: number }
|
||||
export function isGlobalSettings(value: unknown): value is GlobalSettings {
|
||||
if (!value || typeof value !== 'object') return false;
|
||||
const s = value as GlobalSettings, limits = GLOBAL_SETTINGS_LIMITS;
|
||||
return Number.isSafeInteger(s.revision) && s.revision >= 1
|
||||
&& Number.isSafeInteger(s.turnDurationMs) && s.turnDurationMs >= limits.turnDurationMs.min && s.turnDurationMs <= limits.turnDurationMs.max
|
||||
&& Number.isFinite(s.finishCutsceneSpeed) && s.finishCutsceneSpeed >= limits.finishCutsceneSpeed.min && s.finishCutsceneSpeed <= limits.finishCutsceneSpeed.max
|
||||
&& (['matchingDelayMs', 'matchSuccessDelayMs', 'matchPreparationDelayMs'] as const).every(key => Number.isSafeInteger(s[key]) && s[key] >= limits[key].min && s[key] <= limits[key].max);
|
||||
&& (['matchingDelayMs', 'matchSuccessDelayMs', 'matchPreparationDelayMs', 'rankedAcceptTimeoutMs'] as const).every(key => Number.isSafeInteger(s[key]) && s[key] >= limits[key].min && s[key] <= limits[key].max);
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
@@ -6,20 +6,23 @@ import type { EquipmentId } from '../../constants/cosmetics.js';
|
||||
export interface PlayerProfile {
|
||||
playerId: string;
|
||||
displayName: string;
|
||||
nameTag: number;
|
||||
appearance: FighterAppearance;
|
||||
stats: ModeStats;
|
||||
unlockedColors: string[];
|
||||
unlockedEquipment: EquipmentId[];
|
||||
}
|
||||
export interface RankedStatus {
|
||||
status: 'idle' | 'queued' | 'matched';
|
||||
status: 'idle' | 'queued' | 'confirming' | 'matched';
|
||||
profile: PlayerProfile;
|
||||
offer?: { matchId: string; expiresAt: number; serverNow: number; accepted: boolean };
|
||||
match?: LocalMatchState;
|
||||
}
|
||||
export interface LeaderboardEntry {
|
||||
rank: number;
|
||||
playerId: string;
|
||||
displayName: string;
|
||||
nameTag: number;
|
||||
wins: number;
|
||||
best: number;
|
||||
}
|
||||
|
||||
@@ -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
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { MongoClient } from 'mongodb';
|
||||
import { createAnalyticsStore } from '../src/server/analytics-store.js';
|
||||
import { kstDay, ratio, retention } from '../src/server/analytics-report.js';
|
||||
import { getAnalyticsReport, kstDay, ratio, retention } from '../src/server/analytics-report.js';
|
||||
import { createStatsStore } from '../src/server/stats-store.js';
|
||||
import { RETENTION_MS, VISIT_SESSION_MS } from '../constants/analytics.js';
|
||||
|
||||
@@ -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 () => {
|
||||
const databaseName = `dragon_ball_test_analytics_${randomUUID().replaceAll('-', '')}`;
|
||||
const stats = await createStatsStore({ mongoUri: process.env.TEST_MONGODB_URI!, databaseName });
|
||||
|
||||
@@ -162,12 +162,16 @@ function viewHarness(t: import('node:test').TestContext) {
|
||||
const actors = [new Element(), new Element()];
|
||||
const container = new Element();
|
||||
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: {
|
||||
matchMedia: () => ({ matches: false, addEventListener() {}, removeEventListener() {} }),
|
||||
} });
|
||||
const view = createMatchView(container as unknown as HTMLElement, async () => true, () => {}, () => {});
|
||||
t.after(() => {
|
||||
view.destroy();
|
||||
if (previousDocument) Object.defineProperty(globalThis, 'document', previousDocument);
|
||||
else Reflect.deleteProperty(globalThis, 'document');
|
||||
if (previous) Object.defineProperty(globalThis, 'window', previous);
|
||||
else Reflect.deleteProperty(globalThis, 'window');
|
||||
});
|
||||
|
||||
@@ -252,7 +252,7 @@ function domHarness(t: import('node:test').TestContext, reducedMotion = true) {
|
||||
}
|
||||
const actors = [new Element(), new Element()], appended: 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: {
|
||||
matchMedia: () => ({ matches: reducedMotion, addEventListener() {}, removeEventListener() {} }),
|
||||
} };
|
||||
|
||||
@@ -2,9 +2,118 @@ import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { randomBytes, randomUUID } from 'node:crypto';
|
||||
import { MongoClient } from 'mongodb';
|
||||
import type { Db } from 'mongodb';
|
||||
import { createGameSessionStore } from '../src/server/game-session-store.js';
|
||||
import { createRoomStore } from '../src/server/rooms.js';
|
||||
|
||||
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 () => {
|
||||
const client = await new MongoClient(uri!, { serverSelectionTimeoutMS: 5000 }).connect();
|
||||
const db = client.db(`dragon_ball_test_p2p_records_${randomUUID().replaceAll('-', '')}`);
|
||||
|
||||
@@ -9,6 +9,15 @@ import { createGlobalSettingsStore } from '../src/server/global-settings.js';
|
||||
import { createGameSessionStore } from '../src/server/game-session-store.js';
|
||||
import { createRoomStore } from '../src/server/rooms.js';
|
||||
import { createMatchEngine } from '../src/shared/match-engine.js';
|
||||
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 () => {
|
||||
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 bot = await sessions.register(c, { requestId: 'bot', matchId: 'bot', mode: 'bot', expectedPreviousSeq: 0 });
|
||||
assert.equal(bot.settings?.revision, 3); assert.equal(bot.settings?.finishCutsceneSpeed, 3);
|
||||
await db.collection('global_settings').updateOne({ _id: 'global' as any }, { $unset: { matchingDelayMs: '', matchSuccessDelayMs: '', matchPreparationDelayMs: '' } });
|
||||
await db.collection('global_settings').updateOne({ _id: 'global' as any }, { $unset: { matchingDelayMs: '', matchSuccessDelayMs: '', matchPreparationDelayMs: '', rankedAcceptTimeoutMs: '' } });
|
||||
await store.initialize();
|
||||
const migrated = await store.get();
|
||||
assert.equal(migrated.matchingDelayMs, 1000); assert.equal(migrated.matchSuccessDelayMs, 1000);
|
||||
assert.equal(migrated.matchPreparationDelayMs, 3000);
|
||||
assert.equal(migrated.rankedAcceptTimeoutMs, 10000);
|
||||
assert.equal(migrated.finishCutsceneSpeed, 3); assert.equal(migrated.turnDurationMs, 2000);
|
||||
assert.ok(migrated.revision > 3);
|
||||
await store.initialize(); assert.deepEqual(await store.get(), migrated);
|
||||
const changed = await store.update({ ...migrated, matchingDelayMs: 4000, matchSuccessDelayMs: 2000, matchPreparationDelayMs: 5000 });
|
||||
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((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, matchingDelayMs: -1 }), /INVALID_SETTINGS/);
|
||||
} finally { await app.close(); await db.dropDatabase(); await client.close(); }
|
||||
|
||||
@@ -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 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: '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);
|
||||
} finally { await app.close(); await analytics.close(); try { await stats.db.dropDatabase(); } finally { await stats.close(); } }
|
||||
});
|
||||
|
||||
+125
-4
@@ -26,7 +26,9 @@ function documentSurface() {
|
||||
return this.nodes.get(selector)!;
|
||||
}
|
||||
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 = {
|
||||
body: new Element(), createElement: () => new Element(), querySelector: () => new Element(),
|
||||
@@ -38,6 +40,125 @@ function documentSurface() {
|
||||
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 => {
|
||||
const document = documentSurface(), original = Object.getOwnPropertyDescriptor(globalThis, '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 } });
|
||||
engine.start(matchIntroductionDuration(DEFAULT_GLOBAL_SETTINGS, true));
|
||||
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: [],
|
||||
};
|
||||
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');
|
||||
});
|
||||
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: [],
|
||||
});
|
||||
let finishOldRead!: (value: PlayerProfile) => void;
|
||||
@@ -151,7 +272,7 @@ for (const editedDuringLoad of [false, true]) {
|
||||
const cached = normalizeAppearance({ characterId: 0, colorId: 'white' });
|
||||
const remote = normalizeAppearance({ characterId: 1, colorId: 'white', equipment: ['shoes'] });
|
||||
const profile = (appearance: FighterAppearance): PlayerProfile => ({
|
||||
playerId: 'player-a', displayName: 'Alpha', appearance,
|
||||
playerId: 'player-a', displayName: 'Alpha', nameTag: 1, appearance,
|
||||
stats: { wins: 5, losses: 0, draws: 0, current: 5, best: 5 },
|
||||
unlockedColors: ['white'], unlockedEquipment: ['shoes', 'wristbands'],
|
||||
});
|
||||
|
||||
@@ -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 compiled = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.None } }).outputText;
|
||||
const flush = async () => { for (let i = 0; i < 8; i++) await new Promise(r => setImmediate(r)); };
|
||||
async function setup(kind: 'expired' | 'orphan' | 'rematch-wait' | '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 active = { matchId: 'm', seq: 1, mode: 'friend', roomId: 'r' };
|
||||
const session: any = { gameSessionId: 's', token: 't'.repeat(43), stats: emptyPlayerStats(), lastAppliedSeq: kind === 'orphan' ? 0 : 1, ...(kind === 'orphan' ? { activeMatch: active } : {}) };
|
||||
const room: any = { id: 'r', code: 'ABCDEF', currentMatchId: 'm', round: 1, selfIndex: 0, status: 'paired', count: 2, expiresAt: Date.now() + 60000, rematch: [kind === 'rematch-wait', false] };
|
||||
let open = true, pollId = 0;
|
||||
mem.set(GAME_ROOM_KEY, JSON.stringify({ gameSessionId: 's', room }));
|
||||
if (kind !== 'orphan') mem.set(GAME_CHECKPOINT_KEY, JSON.stringify({ gameSessionId: 's', activeMatch: active, checkpoint: { state: {
|
||||
if (!invited) mem.set(GAME_ROOM_KEY, JSON.stringify({ gameSessionId: 's', room }));
|
||||
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',
|
||||
} } }));
|
||||
const send: typeof fetch = async (input, init) => {
|
||||
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 (kind === 'expired') return new Response(JSON.stringify({ ok: false, code: 'ROOM_EXPIRED', message: 'expired' }), { status: 410 });
|
||||
if (path.endsWith('/leave')) open = false;
|
||||
@@ -46,13 +54,33 @@ async function setup(kind: 'expired' | 'orphan' | 'rematch-wait' | 'result') {
|
||||
createClientAnalytics: () => ({ ready() {}, screen() {}, gameFinished() {}, gameStarted() {}, error() {}, click() {} }),
|
||||
createResultReporter: (api: any) => ({ destroy() {}, resumePending: async () => {}, submit: api.record }),
|
||||
createMatchView: () => ({ update() {}, destroy() {} }), createPeer: () => ({}),
|
||||
createLobbyFeatures: () => ({ initialize: async () => false, setReady() {}, active: false }),
|
||||
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() {} }; },
|
||||
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();
|
||||
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 () => {
|
||||
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);
|
||||
|
||||
@@ -9,7 +9,7 @@ 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 get(auth: { token: string; gameSessionId: string }) {
|
||||
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);
|
||||
@@ -45,7 +45,7 @@ test('temporary revalidation errors allow reconnect, invalid sessions remain dis
|
||||
}) as typeof setInterval);
|
||||
let failure: 'temporary' | 'invalid' | undefined;
|
||||
const http = createServer(), io = new Server(http);
|
||||
registerPresence(io, { async get() {
|
||||
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' };
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -8,6 +8,7 @@ import { createRankedService } from '../src/server/ranked-service.js';
|
||||
import type { RoomStore } from '../src/server/rooms.js';
|
||||
import type { Signaling } from '../src/server/signaling.js';
|
||||
import { ManualClock } from './helpers.js';
|
||||
import { DEFAULT_GLOBAL_SETTINGS } from '../constants/global-settings.js';
|
||||
|
||||
function deferred() {
|
||||
let resolve!: () => void;
|
||||
@@ -23,7 +24,7 @@ function memoryDatabase(now: number) {
|
||||
{ _id: 'session-a', playerId: 'player-a', expiresAt: new Date(now + 60000) },
|
||||
{ _id: 'session-b', playerId: 'player-b', expiresAt: new Date(now + 60000) },
|
||||
],
|
||||
player_profiles: [], ranked_matches: [], rooms: [],
|
||||
player_profiles: [], ranked_matches: [], rooms: [], global_settings: [],
|
||||
};
|
||||
const matches = (doc: Record<string, any>, query: Record<string, any> = {}) =>
|
||||
Object.entries(query).every(([key, value]) => {
|
||||
@@ -56,6 +57,113 @@ function memoryDatabase(now: number) {
|
||||
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']) {
|
||||
test(`${url} reserves the player until its write completes before ranked pairing`, { timeout: 5000 }, async () => {
|
||||
const clock = new ManualClock(), { db, records } = memoryDatabase(clock.now());
|
||||
|
||||
+88
-38
@@ -17,11 +17,31 @@ import { EQUIPMENT_IDS } from '../constants/cosmetics.js';
|
||||
import { createRoomStore } from '../src/server/rooms.js';
|
||||
|
||||
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 f.service.join('player-b', f.b.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');
|
||||
@@ -84,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)]);
|
||||
assert.ok(first.every(value => value.status === 'queued'));
|
||||
await assert.rejects(s.assertAvailable('player-a'), /MATCH_PENDING/);
|
||||
const paired = await s.join('player-b', b.gameSessionId);
|
||||
const paired = await pairAndAccept(s, a.gameSessionId, b.gameSessionId);
|
||||
const restored = await s.status('player-a', a2.gameSessionId);
|
||||
assert.equal(paired.match?.matchId, restored.match?.matchId);
|
||||
assert.notEqual(paired.match?.selfIndex, restored.match?.selfIndex);
|
||||
assert.equal(restored.match?.mode, 'friend'); assert.equal(restored.match?.ranked, true);
|
||||
assert.equal((await s.join('player-a', a.gameSessionId)).match?.matchId, paired.match?.matchId);
|
||||
await assert.rejects(s.cancel('player-a', a.gameSessionId), /MATCH_PENDING/);
|
||||
assert.equal((await s.cancel('player-a', a.gameSessionId)).status, 'matched');
|
||||
await assert.rejects(s.status('player-a', b.gameSessionId), /INVALID_SESSION/);
|
||||
assert.equal((await s.profile('player-a')).stats.wins, 0);
|
||||
} finally { await f.close(); }
|
||||
@@ -100,7 +120,7 @@ test('bot and friend wins never change ranked standings, profile stats or cosmet
|
||||
const f = await fixture();
|
||||
try {
|
||||
await f.service.join('player-a', f.a.gameSessionId);
|
||||
const rankedId = (await f.service.join('player-b', f.b.gameSessionId)).match!.matchId;
|
||||
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-a', f.a.gameSessionId, rankedId);
|
||||
const before = await f.service.profile('player-a');
|
||||
@@ -136,23 +156,45 @@ test('bot and friend wins never change ranked standings, profile stats or cosmet
|
||||
} 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();
|
||||
try {
|
||||
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.unlockedEquipment, []);
|
||||
assert.match(original.displayName, /^[가-힣]+-[0-9]{4}$/);
|
||||
for (const displayName of ['손오공', '광고사이트', '<img>', null, undefined]) {
|
||||
await assert.rejects(s.updateProfile('player-a', { displayName } as any), /PROFILE_NAME_READONLY/);
|
||||
assert.equal(original.displayName, '손오공');
|
||||
for (const displayName of ['씨 발', '병_신', '<img>', null, undefined]) {
|
||||
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' }]) {
|
||||
await assert.rejects(s.updateProfile('player-a', { appearance: appearance as any }), /INVALID_APPEARANCE|COLOR_LOCKED/);
|
||||
}
|
||||
const selected = await s.updateProfile('player-a', { appearance: { characterId: 1, colorId: 'white' } });
|
||||
await s.join('player-a', f.a.gameSessionId);
|
||||
const paired = await s.join('player-b', f.b.gameSessionId);
|
||||
const paired = await pairAndAccept(s, f.a.gameSessionId, f.b.gameSessionId);
|
||||
await s.updateProfile('player-a', { appearance: { characterId: 0, colorId: 'white' } });
|
||||
assert.deepEqual(paired.match?.appearances?.[0], selected.appearance);
|
||||
assert.equal((await f.db.collection('player_profiles').findOne({ _id: 'player-a' as any }))?.displayName, original.displayName);
|
||||
@@ -166,12 +208,12 @@ test('profiles keep generated names and validate unlocked appearances, persisten
|
||||
} 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();
|
||||
try {
|
||||
await f.service.updateProfile('player-a', { appearance: { characterId: 1, colorId: 'white' } });
|
||||
await f.service.join('player-a', f.a.gameSessionId);
|
||||
const id = (await f.service.join('player-b', f.b.gameSessionId)).match!.matchId;
|
||||
const id = (await pairAndAccept(f.service, f.a.gameSessionId, f.b.gameSessionId)).match!.matchId;
|
||||
await f.service.leave('player-b', f.b.gameSessionId, id);
|
||||
const expectedStats = (await f.service.profile('player-a')).stats;
|
||||
const records = await f.db.collection('ranked_matches').find().toArray();
|
||||
@@ -179,6 +221,7 @@ test('startup migrates old custom and default names including nonvisiting leader
|
||||
const profiles = f.db.collection<any>('player_profiles');
|
||||
await profiles.updateOne({ _id: 'player-a' }, { $set: { displayName: '광고.example' }, $unset: { nameVersion: '' } });
|
||||
await profiles.updateOne({ _id: 'player-b' }, { $set: { displayName: '무도가', nameVersion: 0 } });
|
||||
await profiles.updateMany({}, { $unset: { nameTag: '' } });
|
||||
const restarted = await createRankedService(f.db, f.clock, 'test');
|
||||
let names: string[];
|
||||
try {
|
||||
@@ -186,13 +229,20 @@ test('startup migrates old custom and default names including nonvisiting leader
|
||||
const board = await restarted.leaderboard('new-viewer');
|
||||
names = board.entries.map(entry => entry.displayName);
|
||||
assert.equal(names.length, 2);
|
||||
for (const name of names) assert.match(name, /^[가-힣]+-[0-9]{4}$/);
|
||||
assert.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);
|
||||
const profile = await restarted.profile('player-a');
|
||||
assert.deepEqual(profile.stats, expectedStats);
|
||||
assert.deepEqual(profile.appearance, normalizeAppearance({ characterId: 1, colorId: 'white', skinTone: 'light', hairStyle: 'black' }));
|
||||
assert.deepEqual(await f.db.collection('ranked_matches').find().toArray(), records);
|
||||
assert.equal((await profiles.findOne({ _id: 'player-b' })).nameVersion, 1);
|
||||
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(); }
|
||||
const again = await createRankedService(f.db, f.clock, 'test');
|
||||
try { assert.deepEqual((await again.leaderboard('new-viewer')).entries.map(entry => entry.displayName), names!); }
|
||||
@@ -241,7 +291,7 @@ test('equipment rewards use canonical thresholds, toggle independently, persist
|
||||
const basicEquipment = ['shoes', 'wristbands', 'headband'] as const;
|
||||
for (let wins = 1; wins <= 20; wins++) {
|
||||
await s.join('player-a', f.a.gameSessionId);
|
||||
const id = (await s.join('player-b', f.b.gameSessionId)).match!.matchId;
|
||||
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-a', f.a.gameSessionId, id);
|
||||
const profile = await s.profile('player-a');
|
||||
@@ -259,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' });
|
||||
assert.deepEqual(stored.appearance, equipped.appearance);
|
||||
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: [] } });
|
||||
assert.deepEqual(paired.match!.appearances![0], equipped.appearance);
|
||||
assert.deepEqual((await s.status('player-a', f.a2.gameSessionId)).match!.appearances![0], equipped.appearance);
|
||||
@@ -305,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);
|
||||
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' } });
|
||||
assert.deepEqual(paired.match!.appearances![0], selected.appearance);
|
||||
assert.deepEqual((await f.service.status('player-a', f.a2.gameSessionId)).match!.appearances![0], selected.appearance);
|
||||
@@ -352,7 +402,7 @@ test('ranked eyewear persists after streak loss, rejects locked or dual eyewear,
|
||||
assert.equal(equipped.displayName, original.displayName);
|
||||
assert.deepEqual((await s.updateProfile('player-a', { appearance: { characterId: 1, colorId: 'white' } })).appearance, equipped.appearance);
|
||||
await s.join('player-a', f.a.gameSessionId);
|
||||
const paired = await s.join('player-b', f.b.gameSessionId);
|
||||
const paired = await pairAndAccept(s, f.a.gameSessionId, f.b.gameSessionId);
|
||||
await s.updateProfile('player-a', { appearance: { ...base, equipment: ['glasses'] } });
|
||||
assert.deepEqual((await s.status('player-a', f.a2.gameSessionId)).match!.appearances![0], equipped.appearance);
|
||||
assert.deepEqual(paired.match!.appearances![0], equipped.appearance);
|
||||
@@ -370,9 +420,9 @@ test('server hides choices, checks costs and deadlines, resolves once and awards
|
||||
try {
|
||||
const { service: s, a, a2, b, clock } = f;
|
||||
await s.join('player-a', a.gameSessionId);
|
||||
const id = (await s.join('player-b', b.gameSessionId)).match!.matchId;
|
||||
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 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/);
|
||||
const version = (await s.status('player-a', a.gameSessionId)).match!.version;
|
||||
const locked = await s.action('player-a', a.gameSessionId, id, 1, 'charge');
|
||||
@@ -413,8 +463,8 @@ test('finished matches retain results on reload then expire when abandoned', int
|
||||
const f = await fixture();
|
||||
try {
|
||||
await f.service.join('player-a', f.a.gameSessionId);
|
||||
const id = (await f.service.join('player-b', f.b.gameSessionId)).match!.matchId;
|
||||
await f.clock.advance(DEFAULT_GLOBAL_SETTINGS.matchingDelayMs + DEFAULT_GLOBAL_SETTINGS.matchSuccessDelayMs + MATCH_COUNTDOWN_MS + TURN_DURATION_MS * 3);
|
||||
const id = (await pairAndAccept(f.service, f.a.gameSessionId, f.b.gameSessionId)).match!.matchId;
|
||||
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');
|
||||
await f.clock.advance(RANKED_FINISHED_TTL_MS - 1);
|
||||
assert.equal((await f.service.status('player-a', f.a2.gameSessionId)).match?.matchId, id);
|
||||
@@ -453,8 +503,8 @@ test('deadline rejects late actions and duplicate terminal writes survive an amb
|
||||
const s = await createRankedService(db, f.clock, 'test');
|
||||
try {
|
||||
await s.join('player-a', f.a.gameSessionId);
|
||||
const id = (await s.join('player-b', f.b.gameSessionId)).match!.matchId;
|
||||
await f.clock.advance(DEFAULT_GLOBAL_SETTINGS.matchingDelayMs + DEFAULT_GLOBAL_SETTINGS.matchSuccessDelayMs + MATCH_COUNTDOWN_MS);
|
||||
const id = (await pairAndAccept(s, f.a.gameSessionId, f.b.gameSessionId)).match!.matchId;
|
||||
await f.clock.advance(MATCH_COUNTDOWN_MS);
|
||||
await s.action('player-a', f.a.gameSessionId, id, 1, 'guard');
|
||||
assert.equal((await s.leaderboard('player-a')).self, undefined);
|
||||
// Jump to the deadline without executing timers: the request itself must settle it.
|
||||
@@ -496,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.db.collection<any>('game_sessions').updateOne({ _id: f.a.gameSessionId }, { $set: { expiresAt: new Date(f.clock.now() - 1) } });
|
||||
assert.equal((await f.service.status('player-a', f.a2.gameSessionId)).status, 'queued');
|
||||
assert.equal((await f.service.join('player-b', f.b.gameSessionId)).status, 'matched');
|
||||
assert.equal((await f.service.join('player-b', f.b.gameSessionId)).status, 'confirming');
|
||||
} finally { await f.close(); }
|
||||
});
|
||||
|
||||
@@ -506,8 +556,8 @@ test('timeouts progress without polling, unlock colors only from authoritative r
|
||||
const { service: s, a, b, clock } = f;
|
||||
for (let n = 0; n < 5; n++) {
|
||||
await s.join('player-a', a.gameSessionId);
|
||||
const id = (await s.join('player-b', b.gameSessionId)).match!.matchId;
|
||||
await clock.advance(DEFAULT_GLOBAL_SETTINGS.matchingDelayMs + DEFAULT_GLOBAL_SETTINGS.matchSuccessDelayMs + MATCH_COUNTDOWN_MS);
|
||||
const id = (await pairAndAccept(s, a.gameSessionId, b.gameSessionId)).match!.matchId;
|
||||
await clock.advance(MATCH_COUNTDOWN_MS);
|
||||
await s.action('player-a', a.gameSessionId, id, 1, 'guard');
|
||||
await clock.advance(TURN_DURATION_MS * 3);
|
||||
assert.equal((await s.status('player-b', b.gameSessionId)).match?.finished?.reason, 'timeout');
|
||||
@@ -517,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.ok(profile.unlockedColors.length > 1);
|
||||
await s.updateProfile('player-a', { appearance: { characterId: 1, colorId: profile.unlockedColors.find(id => id !== 'white')! } });
|
||||
await s.join('player-a', a.gameSessionId); await s.join('player-b', b.gameSessionId);
|
||||
await s.join('player-a', a.gameSessionId); await pairAndAccept(s, a.gameSessionId, b.gameSessionId);
|
||||
await s.close();
|
||||
const restarted = await createRankedService(f.db, clock, 'test');
|
||||
try { assert.equal((await restarted.profile('player-a')).stats.wins, 5); }
|
||||
@@ -536,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 },
|
||||
} });
|
||||
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: {
|
||||
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);
|
||||
const profile = await app.inject({ method: 'POST', url: '/api/profile', headers, payload: { playerId: 'player-b', stats: { wins: 500 } } });
|
||||
assert.equal(profile.statusCode, 200); assert.equal(profile.json().data.playerId, 'player-a');
|
||||
await app.inject({ method: 'POST', url: '/api/ranked/queue', headers });
|
||||
const paired = await f.service.join('player-b', f.b.gameSessionId);
|
||||
const 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: {
|
||||
matchId: paired.match!.matchId, seq: 1, mode: 'friend', outcome: 'win', reason: 'hit',
|
||||
} });
|
||||
@@ -585,8 +635,8 @@ test('a slow leaderboard scan does not block match actions or deadline processin
|
||||
const s = await createRankedService(db, f.clock, 'test');
|
||||
try {
|
||||
await s.join('player-a', f.a.gameSessionId);
|
||||
const id = (await s.join('player-b', f.b.gameSessionId)).match!.matchId;
|
||||
await f.clock.advance(DEFAULT_GLOBAL_SETTINGS.matchingDelayMs + DEFAULT_GLOBAL_SETTINGS.matchSuccessDelayMs + MATCH_COUNTDOWN_MS);
|
||||
const id = (await pairAndAccept(s, f.a.gameSessionId, f.b.gameSessionId)).match!.matchId;
|
||||
await f.clock.advance(MATCH_COUNTDOWN_MS);
|
||||
const board = s.leaderboard('player-a'); await started;
|
||||
const action = s.action('player-a', f.a.gameSessionId, id, 1, 'guard');
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
@@ -628,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')));
|
||||
assert.equal(boards, 1);
|
||||
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);
|
||||
const profile = await s.profile('player-a');
|
||||
assert.equal(profile.stats.wins, 1);
|
||||
@@ -673,7 +723,7 @@ test('save during stalled ranked reads returns one snapshot without retrying or
|
||||
const s = await createRankedService(db, f.clock, 'test');
|
||||
try {
|
||||
await s.join('player-a', f.a.gameSessionId);
|
||||
const id = (await s.join('player-b', f.b.gameSessionId)).match!.matchId;
|
||||
const id = (await pairAndAccept(s, f.a.gameSessionId, f.b.gameSessionId)).match!.matchId;
|
||||
hold = true;
|
||||
const board = s.leaderboard('player-a'), spectator = s.profile('spectator');
|
||||
await started;
|
||||
@@ -737,7 +787,7 @@ test('a personal history read overlapping its own result save cannot repopulate
|
||||
const s = await createRankedService(db, f.clock, 'test');
|
||||
try {
|
||||
await s.join('player-a', f.a.gameSessionId);
|
||||
const id = (await s.join('player-b', f.b.gameSessionId)).match!.matchId;
|
||||
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.
|
||||
f.clock.time += RANKED_STATS_CACHE_TTL_MS;
|
||||
hold = true;
|
||||
@@ -777,8 +827,8 @@ test('ranked timer reports only a safe failure code and retries persistence even
|
||||
});
|
||||
try {
|
||||
await s.join('player-a', f.a.gameSessionId);
|
||||
await s.join('player-b', f.b.gameSessionId);
|
||||
await f.clock.advance(DEFAULT_GLOBAL_SETTINGS.matchingDelayMs + DEFAULT_GLOBAL_SETTINGS.matchSuccessDelayMs + MATCH_COUNTDOWN_MS + TURN_DURATION_MS * 3);
|
||||
await pairAndAccept(s, f.a.gameSessionId, f.b.gameSessionId);
|
||||
await f.clock.advance(MATCH_COUNTDOWN_MS + TURN_DURATION_MS * 3);
|
||||
await logged;
|
||||
assert.deepEqual(codes, ['RANKED_BACKGROUND_FAILED']);
|
||||
assert.equal(await records.countDocuments(), 0);
|
||||
|
||||
@@ -6,6 +6,40 @@ import { io } from 'socket.io-client';
|
||||
import { createGameServer } from '../src/server/create-server.js';
|
||||
import { ManualClock } from './helpers.js';
|
||||
const uri = process.env.TEST_MONGODB_URI;
|
||||
test('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 () => {
|
||||
const databaseName = 'dragon_ball_test_server_' + randomUUID().replaceAll('-', ''), clock = new ManualClock();
|
||||
|
||||
@@ -1,7 +1,33 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import { resolve } from 'node:path';
|
||||
import { GAME_NAME, GAME_DESCRIPTION, GAME_URL, GAME_SHARE_IMAGE } from './constants/branding.js';
|
||||
export default defineConfig({
|
||||
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,
|
||||
rollupOptions: { input: { game: resolve('src/client/index.html'), admin: resolve('src/client/admin/analytics/index.html') } },
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user