feat: replace generated nicknames with filtered user input

This commit is contained in:
2026-10-26 18:04:09 +09:00
parent 5cef1c6f1e
commit fc45534c7d
11 changed files with 147 additions and 40 deletions
+8
View File
@@ -0,0 +1,8 @@
export const PROFILE_NAME_MIN_LENGTH = 2;
export const PROFILE_NAME_MAX_LENGTH = 16;
export const PROFILE_NAME_BLOCKED_WORDS = [
'시발', '씨발', '씨팔', '시팔', '씨바', '씨벌', '씨부랄', '씹', '좆', '좃', '존나',
'병신', '븅신', '빙신', '개새끼', '새끼', '개새기', '개색기', '개색끼', '지랄', '염병',
'꺼져', '닥쳐', '느금마', '니애미', '니애비', '애미뒤진', '창녀', '걸레',
'ㅅㅂ', 'ㅆㅂ', 'ㅂㅅ', 'ㅈㄹ', 'ㅈㄴ', 'fuck', 'shit', 'bitch', 'asshole', 'cunt',
] as const;
-5
View File
@@ -7,8 +7,3 @@ export const RANKED_STATS_CACHE_LIMIT = 1000;
export const RANKED_STATS_CACHE_TTL_MS = 5 * 60 * 1000; export const RANKED_STATS_CACHE_TTL_MS = 5 * 60 * 1000;
export const RANKED_LEADERBOARD_CACHE_LIMIT = 1000; export const RANKED_LEADERBOARD_CACHE_LIMIT = 1000;
export const RANKED_LEADERBOARD_CACHE_TTL_MS = 5000; export const RANKED_LEADERBOARD_CACHE_TTL_MS = 5000;
export const PROFILE_NAME_VERSION = 1;
export const PROFILE_NAME_PREFIXES = ['푸른', '밝은', '고요한', '빛나는', '온화한', '맑은', '은빛', '씩씩한'] as const;
export const PROFILE_NAME_NOUNS = ['구름', '바람', '별빛', '노을', '하늘', '달빛', '물결', '나무'] as const;
export const PROFILE_NAME_SUFFIX_MIN = 1000;
export const PROFILE_NAME_SUFFIX_MAX = 10000;
+2 -1
View File
@@ -15,7 +15,8 @@ export const GAME_API_MESSAGES: Record<string, string> = {
ROOM_EXPIRED: '방이 만료되었습니다. 새 방을 만드세요.', ROOM_EXPIRED: '방이 만료되었습니다. 새 방을 만드세요.',
SERVER_ERROR: '처리하지 못했습니다. 다시 시도하세요.', SERVER_ERROR: '처리하지 못했습니다. 다시 시도하세요.',
INVALID_PROFILE: '프로필 정보를 확인하세요.', INVALID_PROFILE: '프로필 정보를 확인하세요.',
PROFILE_NAME_READONLY: '이름은 자동으로 지정되며 변경할 수 없습니다.', INVALID_PROFILE_NAME: '닉네임은 한글·영문·숫자·공백·_·-로 2~16자 입력하세요.',
PROFILE_NAME_PROFANITY: '사용할 수 없는 표현이 포함되어 있습니다. 다른 닉네임을 입력하세요.',
INVALID_APPEARANCE: '캐릭터, 머리, 도복색과 장비를 확인하세요.', INVALID_APPEARANCE: '캐릭터, 머리, 도복색과 장비를 확인하세요.',
COLOR_LOCKED: '아직 획득하지 않은 도복입니다.', COLOR_LOCKED: '아직 획득하지 않은 도복입니다.',
EQUIPMENT_LOCKED: '아직 획득하지 않은 장비입니다.', EQUIPMENT_LOCKED: '아직 획득하지 않은 장비입니다.',
+4
View File
@@ -1,4 +1,8 @@
.lobby-tools { display: flex; gap: 10px; margin-top: 16px; } .lobby-tools { display: flex; gap: 10px; margin-top: 16px; }
#nickname-dialog { max-width: 440px; }
#nickname-dialog form { display: grid; gap: 16px; }
#nickname-dialog input { width: 100%; min-width: 0; box-sizing: border-box; }
#nickname-notice:empty { display: none; }
.ranked-queue-actions { display: flex; justify-content: center; gap: 12px; } .ranked-queue-actions { display: flex; justify-content: center; gap: 12px; }
.ranked-queue .accept-time { font-size: 28px; font-weight: 700; font-variant-numeric: tabular-nums; } .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[data-screen=ranked-queue] main { padding: 0; }
+42 -3
View File
@@ -1,5 +1,8 @@
import { COSMETIC_COLORS, COSMETIC_EQUIPMENT, EYEWEAR_IDS, HAIR_STYLES, SKIN_TONES, type EquipmentId, type HairStyle } from '../../constants/cosmetics.js'; import { COSMETIC_COLORS, COSMETIC_EQUIPMENT, EYEWEAR_IDS, HAIR_STYLES, SKIN_TONES, type EquipmentId, type HairStyle } from '../../constants/cosmetics.js';
import { RANKED_POLL_MS } from '../../constants/ranked.js'; import { RANKED_POLL_MS } from '../../constants/ranked.js';
import { PROFILE_NAME_MAX_LENGTH } from '../../constants/profile-name.js';
import { GAME_API_MESSAGES } from '../../constants/recording.js';
import { profileNameError } from '../shared/profile-name.js';
import { appearanceKey, normalizeAppearance, type FighterAppearance } from '../shared/cosmetics.js'; import { appearanceKey, normalizeAppearance, type FighterAppearance } from '../shared/cosmetics.js';
import type { Leaderboard, PlayerProfile, RankedStatus } from '../shared/ranked-types.js'; import type { Leaderboard, PlayerProfile, RankedStatus } from '../shared/ranked-types.js';
import type { ActionId } from '../shared/types.js'; import type { ActionId } from '../shared/types.js';
@@ -26,7 +29,7 @@ export function createLobbyFeatures(options: LobbyOptions) {
actions.prepend(rankedStart); actions.prepend(rankedStart);
document.getElementById('bot-start')!.classList.remove('primary'); document.getElementById('bot-start')!.classList.remove('primary');
const tools = document.createElement('div'); tools.className = 'lobby-tools'; const tools = document.createElement('div'); tools.className = 'lobby-tools';
tools.innerHTML = '<button id="leaderboard-open" disabled>리더보드</button><button id="appearance-open" disabled>캐릭터·장비</button>'; tools.innerHTML = '<button id="leaderboard-open" disabled>리더보드</button><button id="appearance-open" disabled>캐릭터·장비</button><button id="nickname-open" disabled>닉네임 변경</button>';
actions.after(tools); actions.after(tools);
const queue = document.createElement('section'); queue.id = 'ranked-queue'; queue.className = 'waiting ranked-queue'; queue.hidden = true; const queue = document.createElement('section'); queue.id = 'ranked-queue'; queue.className = 'waiting ranked-queue'; queue.hidden = true;
queue.innerHTML = '<div class="queue-fighter" aria-hidden="true"></div><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>'; 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>';
@@ -37,6 +40,41 @@ export function createLobbyFeatures(options: LobbyOptions) {
leaderboardDialog.innerHTML = '<div class="dialog-top"><h2>랭킹 리더보드</h2><button class="quiet" aria-label="리더보드 닫기">닫기</button></div><p class="ranking-self"></p><p class="ranking-order">최대 연승순 · 동률이면 누적 승순</p><div class="leaderboard-content" aria-live="polite"></div>'; leaderboardDialog.innerHTML = '<div class="dialog-top"><h2>랭킹 리더보드</h2><button class="quiet" aria-label="리더보드 닫기">닫기</button></div><p class="ranking-self"></p><p class="ranking-order">최대 연승순 · 동률이면 누적 승순</p><div class="leaderboard-content" aria-live="polite"></div>';
document.body.append(appearanceDialog, leaderboardDialog); document.body.append(appearanceDialog, leaderboardDialog);
const select = <T extends HTMLElement = HTMLElement>(root: ParentNode, selector: string) => root.querySelector<T>(selector)!; const select = <T extends HTMLElement = HTMLElement>(root: ParentNode, selector: string) => root.querySelector<T>(selector)!;
const nameDialog = document.createElement('dialog'); nameDialog.id = 'nickname-dialog';
nameDialog.setAttribute('aria-labelledby', 'nickname-title');
nameDialog.innerHTML = `<form><div class="dialog-top"><h2 id="nickname-title">닉네임 설정</h2><button type="button" class="quiet">닫기</button></div><label for="nickname-input">닉네임</label><input id="nickname-input" name="nickname" maxlength="${PROFILE_NAME_MAX_LENGTH}" autocomplete="off" aria-describedby="nickname-notice" required><p id="nickname-notice" role="status"></p><button type="submit" class="primary">저장</button></form>`;
document.body.append(nameDialog);
const nameInput = select<HTMLInputElement>(nameDialog, 'input');
const nameNotice = select(nameDialog, 'p');
const nameSave = select<HTMLButtonElement>(nameDialog, '[type=submit]');
const nameClose = select<HTMLButtonElement>(nameDialog, '[type=button]');
const nameOpen = document.getElementById('nickname-open') as HTMLButtonElement;
let nameSaving = false, finishName: (() => void) | undefined;
function openName() {
nameInput.value = profile?.displayName ?? ''; nameNotice.textContent = '';
nameClose.hidden = !profile?.displayName;
nameDialog.showModal(); nameInput.focus();
}
nameOpen.onclick = openName;
nameClose.onclick = () => { if (!nameSaving && profile?.displayName) nameDialog.close(); };
nameDialog.addEventListener('cancel', event => { if (nameSaving || !profile?.displayName) event.preventDefault(); });
select(nameDialog, 'form').addEventListener('submit', async event => {
event.preventDefault();
if (nameSaving) return;
const error = profileNameError(nameInput.value);
if (error) { nameNotice.textContent = GAME_API_MESSAGES[error]; return; }
nameSaving = true; nameSave.disabled = nameClose.disabled = true;
try {
const next = await api.request<PlayerProfile>('/api/profile', 'POST', { displayName: nameInput.value });
profileRevision++; updateProfile(next);
nameDialog.close(); finishName?.(); finishName = undefined;
} catch (error) { nameNotice.textContent = error instanceof Error ? error.message : '닉네임을 저장하지 못했습니다.'; }
finally { nameSaving = false; nameSave.disabled = nameClose.disabled = false; }
});
async function requireName() {
if (profile?.displayName) return;
await new Promise<void>(resolve => { finishName = resolve; openName(); });
}
const profileNotice = select(appearanceDialog, '.profile-notice'); const profileNotice = select(appearanceDialog, '.profile-notice');
const equip = select<HTMLButtonElement>(appearanceDialog, '.equip'); const equip = select<HTMLButtonElement>(appearanceDialog, '.equip');
const appearanceOpen = document.getElementById('appearance-open') as HTMLButtonElement; const appearanceOpen = document.getElementById('appearance-open') as HTMLButtonElement;
@@ -258,13 +296,14 @@ export function createLobbyFeatures(options: LobbyOptions) {
return { return {
get active() { return active; }, get active() { return active; },
get busy() { return busy; }, get busy() { return busy; },
setReady(ready: boolean) { rankedStart.disabled = !ready || !profile || busy || active; appearanceOpen.disabled = !ready || !profile || busy || active; leaderboardOpen.disabled = !ready || !profile || busy || active; }, setReady(ready: boolean) { rankedStart.disabled = !ready || !profile || busy || active; appearanceOpen.disabled = !ready || !profile || busy || active; leaderboardOpen.disabled = !ready || !profile || busy || active; nameOpen.disabled = !ready || !profile || busy || active; },
async initialize(restoreRanked: boolean) { async initialize(restoreRanked: boolean) {
generation++; clearTimeout(timer); clearTimeout(confirmationTimer); generation++; clearTimeout(timer); clearTimeout(confirmationTimer);
busy = false; cancel.disabled = false; busy = false; cancel.disabled = false;
if (!restoreRanked) { updateProfile(await api.request<PlayerProfile>('/api/profile')); return false; } if (!restoreRanked) { updateProfile(await api.request<PlayerProfile>('/api/profile')); await requireName(); return false; }
const status = await api.request<RankedStatus>('/api/ranked/status'); const status = await api.request<RankedStatus>('/api/ranked/status');
updateProfile(status.profile); latest = status; active = status.status !== 'idle'; updateProfile(status.profile); latest = status; active = status.status !== 'idle';
await requireName();
if (active) { consume(status); schedule(); } if (active) { consume(status); schedule(); }
return active; return active;
}, },
+1 -1
View File
@@ -14,7 +14,7 @@ export function registerRankedApi(app: FastifyInstance, sessions: GameSessionSto
gameRoute(app, 'GET', '/api/profile', async request => service.profile((await identity(request)).playerId)); gameRoute(app, 'GET', '/api/profile', async request => service.profile((await identity(request)).playerId));
gameRoute(app, 'POST', '/api/profile', async request => { gameRoute(app, 'POST', '/api/profile', async request => {
const { playerId } = await identity(request); const { playerId } = await identity(request);
return service.updateProfile(playerId, request.body as { appearance?: FighterAppearance }); return service.updateProfile(playerId, request.body as { displayName?: string; appearance?: FighterAppearance });
}); });
gameRoute(app, 'GET', '/api/ranked/leaderboard', async request => service.leaderboard((await identity(request)).playerId)); gameRoute(app, 'GET', '/api/ranked/leaderboard', async request => service.leaderboard((await identity(request)).playerId));
for (const [url, method] of [['status', 'GET'], ['queue', 'POST']] as const) { for (const [url, method] of [['status', 'GET'], ['queue', 'POST']] as const) {
+14 -19
View File
@@ -1,8 +1,7 @@
import { randomInt, randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import type { Db } from 'mongodb'; import type { Db } from 'mongodb';
import { COSMETIC_COLORS, EQUIPMENT_IDS, EYEWEAR_IDS, HAIR_STYLES, SKIN_TONES } from '../../constants/cosmetics.js'; import { COSMETIC_COLORS, EQUIPMENT_IDS, EYEWEAR_IDS, HAIR_STYLES, SKIN_TONES } from '../../constants/cosmetics.js';
import { import {
PROFILE_NAME_NOUNS, PROFILE_NAME_PREFIXES, PROFILE_NAME_SUFFIX_MAX, PROFILE_NAME_SUFFIX_MIN, PROFILE_NAME_VERSION,
RANKED_FINISHED_TTL_MS, RANKED_LEADERBOARD_LIMIT, RANKED_QUEUE_TTL_MS, RANKED_SAVE_RETRY_MS, RANKED_FINISHED_TTL_MS, RANKED_LEADERBOARD_LIMIT, RANKED_QUEUE_TTL_MS, RANKED_SAVE_RETRY_MS,
RANKED_STATS_CACHE_LIMIT, RANKED_STATS_CACHE_TTL_MS, RANKED_LEADERBOARD_CACHE_LIMIT, RANKED_LEADERBOARD_CACHE_TTL_MS, RANKED_STATS_CACHE_LIMIT, RANKED_STATS_CACHE_TTL_MS, RANKED_LEADERBOARD_CACHE_LIMIT, RANKED_LEADERBOARD_CACHE_TTL_MS,
} from '../../constants/ranked.js'; } from '../../constants/ranked.js';
@@ -16,9 +15,10 @@ import { systemClock, type Clock } from './clock.js';
import { ApiError, validId } from './game-session-store.js'; import { ApiError, validId } from './game-session-store.js';
import { createGlobalSettingsStore } from './global-settings.js'; import { createGlobalSettingsStore } from './global-settings.js';
import { createEmotes } from '../shared/emotes.js'; import { createEmotes } from '../shared/emotes.js';
import { profileNameError } from '../shared/profile-name.js';
interface ProfileDocument { interface ProfileDocument {
_id: string; displayName: string; nameVersion?: number; appearance: FighterAppearance; environment: string; createdAt: Date; updatedAt: Date; _id: string; displayName: string; appearance: FighterAppearance; environment: string; createdAt: Date; updatedAt: Date;
} }
interface TerminalMatch { interface TerminalMatch {
_id: string; playerIds: Pair<string>; appearances: Pair<FighterAppearance>; winnerIndex: 0 | 1 | null; _id: string; playerIds: Pair<string>; appearances: Pair<FighterAppearance>; winnerIndex: 0 | 1 | null;
@@ -34,21 +34,11 @@ interface RankedMatch {
terminal?: TerminalMatch; saved: boolean; finishedExpiresAt?: Pair<number>; terminal?: TerminalMatch; saved: boolean; finishedExpiresAt?: Pair<number>;
} }
const emptyStats = (): ModeStats => ({ wins: 0, losses: 0, draws: 0, current: 0, best: 0 }); const emptyStats = (): ModeStats => ({ wins: 0, losses: 0, draws: 0, current: 0, best: 0 });
function generatedName(): string {
return `${PROFILE_NAME_PREFIXES[randomInt(PROFILE_NAME_PREFIXES.length)]}${PROFILE_NAME_NOUNS[randomInt(PROFILE_NAME_NOUNS.length)]}-${randomInt(PROFILE_NAME_SUFFIX_MIN, PROFILE_NAME_SUFFIX_MAX)}`;
}
// One service owns the queue and all match clocks in the established single-process server. // One service owns the queue and all match clocks in the established single-process server.
export async function createRankedService(db: Db, clock: Clock = systemClock, environment = 'production', onError?: (code: string) => void) { export async function createRankedService(db: Db, clock: Clock = systemClock, environment = 'production', onError?: (code: string) => void) {
const profiles = db.collection<ProfileDocument>('player_profiles'); const profiles = db.collection<ProfileDocument>('player_profiles');
const results = db.collection<TerminalMatch>('ranked_matches'); const results = db.collection<TerminalMatch>('ranked_matches');
// Finish migrating every legacy name before any profile or leaderboard can be served.
// The version guard makes interrupted migrations/restarts preserve assigned names.
for await (const profile of profiles.find({ nameVersion: { $ne: PROFILE_NAME_VERSION } })) {
await profiles.updateOne({ _id: profile._id, nameVersion: { $ne: PROFILE_NAME_VERSION } }, { $set: {
displayName: generatedName(), nameVersion: PROFILE_NAME_VERSION, updatedAt: new Date(clock.now()),
} });
}
await results.createIndex({ playerIds: 1, order: 1 }); await results.createIndex({ playerIds: 1, order: 1 });
let resultOrder = (await results.find().sort({ order: -1 }).limit(1).next())?.order ?? 0; let resultOrder = (await results.find().sort({ order: -1 }).limit(1).next())?.order ?? 0;
const queue = new Map<string, QueueEntry>(); const queue = new Map<string, QueueEntry>();
@@ -129,7 +119,7 @@ export async function createRankedService(db: Db, clock: Clock = systemClock, en
const stats = totals.get(id) ?? emptyStats(); addResult(stats, result, id); totals.set(id, stats); const stats = totals.get(id) ?? emptyStats(); addResult(stats, result, id); totals.set(id, stats);
} }
const entries = (await profiles.find({ _id: { $in: [...totals.keys()] } }).toArray()).map(profile => ({ playerId: profile._id, const entries = (await 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, 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)) })).sort((a, b) => b.best - a.best || b.wins - a.wins || (a.playerId < b.playerId ? -1 : a.playerId > b.playerId ? 1 : 0))
.map((entry, index) => ({ rank: index + 1, ...entry })); .map((entry, index) => ({ rank: index + 1, ...entry }));
// Return an overlapping snapshot without caching it; retrying here can starve // Return an overlapping snapshot without caching it; retrying here can starve
@@ -144,12 +134,12 @@ export async function createRankedService(db: Db, clock: Clock = systemClock, en
if (!validId(playerId)) throw new ApiError(400, 'INVALID_PROFILE'); if (!validId(playerId)) throw new ApiError(400, 'INVALID_PROFILE');
const now = new Date(clock.now()); const now = new Date(clock.now());
await profiles.updateOne({ _id: playerId }, { $setOnInsert: { await profiles.updateOne({ _id: playerId }, { $setOnInsert: {
displayName: generatedName(), nameVersion: PROFILE_NAME_VERSION, displayName: '',
appearance: { ...DEFAULT_APPEARANCE }, environment, createdAt: now, updatedAt: now, appearance: { ...DEFAULT_APPEARANCE }, environment, createdAt: now, updatedAt: now,
} }, { upsert: true }); } }, { upsert: true });
const doc = (await profiles.findOne({ _id: playerId }))!; const doc = (await profiles.findOne({ _id: playerId }))!;
const stats = await readStats(playerId); const stats = await readStats(playerId);
return { playerId, displayName: doc.displayName, appearance: normalizeAppearance(doc.appearance), stats, return { playerId, displayName: profileNameError(doc.displayName) ? '' : doc.displayName, appearance: normalizeAppearance(doc.appearance), stats,
unlockedColors: availableColorIds(stats), unlockedEquipment: availableEquipmentIds(stats) }; unlockedColors: availableColorIds(stats), unlockedEquipment: availableEquipmentIds(stats) };
} }
async function persist(match: RankedMatch) { async function persist(match: RankedMatch) {
@@ -286,12 +276,16 @@ export async function createRankedService(db: Db, clock: Clock = systemClock, en
if (closed) throw new ApiError(503, 'RANKED_CLOSED'); if (closed) throw new ApiError(503, 'RANKED_CLOSED');
return readProfile(playerId); return readProfile(playerId);
}, },
updateProfile(playerId: string, input: { appearance?: FighterAppearance }): Promise<PlayerProfile> { updateProfile(playerId: string, input: { displayName?: string; appearance?: FighterAppearance }): Promise<PlayerProfile> {
return serial(async () => { return serial(async () => {
if (!input || typeof input !== 'object' || Array.isArray(input)) throw new ApiError(400, 'INVALID_PROFILE'); if (!input || typeof input !== 'object' || Array.isArray(input)) throw new ApiError(400, 'INVALID_PROFILE');
if (Object.hasOwn(input, 'displayName')) throw new ApiError(403, 'PROFILE_NAME_READONLY'); if (Object.hasOwn(input, 'displayName')) {
const error = profileNameError(input.displayName);
if (error) throw new ApiError(400, error);
}
const profile = await readProfile(playerId); const profile = await readProfile(playerId);
const update: Partial<ProfileDocument> = {}; const update: Partial<ProfileDocument> = {};
if (Object.hasOwn(input, 'displayName')) update.displayName = input.displayName!.normalize('NFKC').trim();
if (input.appearance !== undefined) { if (input.appearance !== undefined) {
const appearance = input.appearance; const appearance = input.appearance;
if (!appearance || typeof appearance !== 'object' || Array.isArray(appearance) if (!appearance || typeof appearance !== 'object' || Array.isArray(appearance)
@@ -312,6 +306,7 @@ export async function createRankedService(db: Db, clock: Clock = systemClock, en
update.appearance = next; update.appearance = next;
} }
if (Object.keys(update).length) await profiles.updateOne({ _id: playerId }, { $set: { ...update, updatedAt: new Date(clock.now()) } }); if (Object.keys(update).length) await profiles.updateOne({ _id: playerId }, { $set: { ...update, updatedAt: new Date(clock.now()) } });
if (update.displayName !== undefined) { resultRevision++; leaderboardCache = undefined; leaderboardRead = undefined; }
return readProfile(playerId); return readProfile(playerId);
}); });
}, },
@@ -329,7 +324,7 @@ export async function createRankedService(db: Db, clock: Clock = systemClock, en
const pending = leaderboardRead ??= scanLeaderboard(); const pending = leaderboardRead ??= scanLeaderboard();
try { try {
const entries = await pending, self = entries.find(entry => entry.playerId === playerId); const entries = await pending, self = entries.find(entry => entry.playerId === playerId);
if (leaderboardCache) { if (leaderboardCache && leaderboardRead === pending) {
leaderboardCache.selves.delete(playerId); leaderboardCache.selves.set(playerId, self); leaderboardCache.selves.delete(playerId); leaderboardCache.selves.set(playerId, self);
if (leaderboardCache.selves.size > RANKED_LEADERBOARD_CACHE_LIMIT) { if (leaderboardCache.selves.size > RANKED_LEADERBOARD_CACHE_LIMIT) {
leaderboardCache.selves.delete(leaderboardCache.selves.keys().next().value!); leaderboardCache.selves.delete(leaderboardCache.selves.keys().next().value!);
+14
View File
@@ -0,0 +1,14 @@
import { PROFILE_NAME_BLOCKED_WORDS, PROFILE_NAME_MAX_LENGTH, PROFILE_NAME_MIN_LENGTH } from '../../constants/profile-name.js';
// 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';
}
+35 -1
View File
@@ -26,7 +26,9 @@ function documentSurface() {
return this.nodes.get(selector)!; return this.nodes.get(selector)!;
} }
addEventListener(name: string, listener: (event: any) => void) { this.listeners.set(name, listener); } addEventListener(name: string, listener: (event: any) => void) { this.listeners.set(name, listener); }
prepend() {} after() {} append() {} showModal() {} close() {} open = false;
prepend() {} after() {} append() {} setAttribute() {} focus() {}
showModal() { this.open = true; } close() { this.open = false; }
} }
const document = { const document = {
body: new Element(), createElement: () => new Element(), querySelector: () => new Element(), body: new Element(), createElement: () => new Element(), querySelector: () => new Element(),
@@ -38,6 +40,38 @@ function documentSurface() {
return document; return document;
} }
test('first visit waits for a saved nickname and keeps the form open on rejection', async t => {
const document = documentSurface(), original = Object.getOwnPropertyDescriptor(globalThis, 'document');
Object.defineProperty(globalThis, 'document', { configurable: true, value: document });
t.after(() => { if (original) Object.defineProperty(globalThis, 'document', original); else Reflect.deleteProperty(globalThis, 'document'); });
const profile: PlayerProfile = { playerId: 'a', displayName: '', 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);
});
test('ranked confirmation shows accept, waits for opponent and cancels by offer id', async t => { test('ranked confirmation shows accept, waits for opponent and cancels by offer id', async t => {
const document = documentSurface(), original = Object.getOwnPropertyDescriptor(globalThis, 'document'); const document = documentSurface(), original = Object.getOwnPropertyDescriptor(globalThis, 'document');
Object.defineProperty(globalThis, 'document', { configurable: true, value: document }); Object.defineProperty(globalThis, 'document', { configurable: true, value: document });
+11
View File
@@ -0,0 +1,11 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { profileNameError } from '../src/shared/profile-name.js';
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');
});
+16 -10
View File
@@ -156,16 +156,17 @@ test('bot and friend wins never change ranked standings, profile stats or cosmet
} finally { await f.close(); } } finally { await f.close(); }
}); });
test('profiles keep generated names and validate unlocked appearances, persistence and pairing snapshots', integration, async () => { test('profiles save filtered names and validate unlocked appearances, persistence and pairing snapshots', integration, async () => {
const f = await fixture(); const f = await fixture();
try { try {
const s = f.service; const s = f.service;
const original = await s.profile('player-a'); assert.equal((await s.profile('player-a')).displayName, '');
const original = await s.updateProfile('player-a', { displayName: '손오공' });
assert.deepEqual(original.appearance, normalizeAppearance({ characterId: 0, colorId: 'white' })); assert.deepEqual(original.appearance, normalizeAppearance({ characterId: 0, colorId: 'white' }));
assert.deepEqual(original.unlockedEquipment, []); assert.deepEqual(original.unlockedEquipment, []);
assert.match(original.displayName, /^[가-힣]+-[0-9]{4}$/); assert.equal(original.displayName, '손오공');
for (const displayName of ['손오공', '광고사이트', '<img>', null, undefined]) { for (const displayName of ['씨 발', '병_신', '<img>', null, undefined]) {
await assert.rejects(s.updateProfile('player-a', { displayName } as any), /PROFILE_NAME_READONLY/); await assert.rejects(s.updateProfile('player-a', { displayName } as any), /PROFILE_NAME_PROFANITY|INVALID_PROFILE_NAME/);
} }
for (const appearance of [{ characterId: 2, colorId: 'white' }, { characterId: 0, colorId: 'bogus' }, { characterId: 0, colorId: 'orange' }]) { for (const appearance of [{ characterId: 2, colorId: 'white' }, { characterId: 0, colorId: 'bogus' }, { characterId: 0, colorId: 'orange' }]) {
await assert.rejects(s.updateProfile('player-a', { appearance: appearance as any }), /INVALID_APPEARANCE|COLOR_LOCKED/); await assert.rejects(s.updateProfile('player-a', { appearance: appearance as any }), /INVALID_APPEARANCE|COLOR_LOCKED/);
@@ -186,7 +187,7 @@ test('profiles keep generated names and validate unlocked appearances, persisten
} finally { await f.close(); } } finally { await f.close(); }
}); });
test('startup migrates old custom and default names including nonvisiting leaderboard players exactly once', integration, async () => { test('startup preserves old names and hides invalid leaderboard names without rewriting profiles', integration, async () => {
const f = await fixture(); const f = await fixture();
try { try {
await f.service.updateProfile('player-a', { appearance: { characterId: 1, colorId: 'white' } }); await f.service.updateProfile('player-a', { appearance: { characterId: 1, colorId: 'white' } });
@@ -206,13 +207,18 @@ test('startup migrates old custom and default names including nonvisiting leader
const board = await restarted.leaderboard('new-viewer'); const board = await restarted.leaderboard('new-viewer');
names = board.entries.map(entry => entry.displayName); names = board.entries.map(entry => entry.displayName);
assert.equal(names.length, 2); assert.equal(names.length, 2);
for (const name of names) assert.match(name, /^[가-힣]+-[0-9]{4}$/); assert.deepEqual(names, ['플레이어', '무도가']);
assert.equal(board.entries[0].wins, 1); assert.equal(board.entries[0].wins, 1);
const profile = await restarted.profile('player-a'); const profile = await restarted.profile('player-a');
assert.deepEqual(profile.stats, expectedStats); assert.deepEqual(profile.stats, expectedStats);
assert.deepEqual(profile.appearance, normalizeAppearance({ characterId: 1, colorId: 'white', skinTone: 'light', hairStyle: 'black' })); assert.deepEqual(profile.appearance, normalizeAppearance({ characterId: 1, colorId: 'white', skinTone: 'light', hairStyle: 'black' }));
assert.deepEqual(await f.db.collection('ranked_matches').find().toArray(), records); assert.deepEqual(await f.db.collection('ranked_matches').find().toArray(), records);
assert.equal((await profiles.findOne({ _id: 'player-b' })).nameVersion, 1); assert.equal((await profiles.findOne({ _id: 'player-b' })).nameVersion, 0);
assert.equal((await profiles.findOne({ _id: 'player-a' })).displayName, '광고.example');
assert.equal(profile.displayName, '');
await restarted.updateProfile('player-a', { displayName: '새이름' });
names = (await restarted.leaderboard('new-viewer')).entries.map(entry => entry.displayName);
assert.deepEqual(names, ['새이름', '무도가']);
} finally { await restarted.close(); } } finally { await restarted.close(); }
const again = await createRankedService(f.db, f.clock, 'test'); const again = await createRankedService(f.db, f.clock, 'test');
try { assert.deepEqual((await again.leaderboard('new-viewer')).entries.map(entry => entry.displayName), names!); } try { assert.deepEqual((await again.leaderboard('new-viewer')).entries.map(entry => entry.displayName), names!); }
@@ -556,11 +562,11 @@ test('ranked HTTP authenticates identity and ignores client results for ranked a
appearance: { characterId: 0, colorId: 'white', equipment: ['headband'] }, stats: { wins: 999, best: 999 }, appearance: { characterId: 0, colorId: 'white', equipment: ['headband'] }, stats: { wins: 999, best: 999 },
} }); } });
assert.equal(locked.statusCode, 403); assert.equal(locked.json().code, 'EQUIPMENT_LOCKED'); assert.equal(locked.statusCode, 403); assert.equal(locked.json().code, 'EQUIPMENT_LOCKED');
for (const displayName of ['광고사이트', '<script>alert(1)</script>', '', null, { nameVersion: 1 }]) { for (const displayName of ['씨발', '<script>alert(1)</script>', '', null, { nameVersion: 1 }]) {
const rejected = await app.inject({ method: 'POST', url: '/api/profile', headers, payload: { const rejected = await app.inject({ method: 'POST', url: '/api/profile', headers, payload: {
displayName, nameVersion: 1, appearance: { characterId: 1, colorId: 'white' }, displayName, nameVersion: 1, appearance: { characterId: 1, colorId: 'white' },
} }); } });
assert.equal(rejected.statusCode, 403); assert.equal(rejected.json().code, 'PROFILE_NAME_READONLY'); assert.equal(rejected.statusCode, 400); assert.match(rejected.json().code, /PROFILE_NAME_PROFANITY|INVALID_PROFILE_NAME/);
} }
assert.deepEqual(await f.service.profile('player-a'), original); assert.deepEqual(await f.service.profile('player-a'), original);
const profile = await app.inject({ method: 'POST', url: '/api/profile', headers, payload: { playerId: 'player-b', stats: { wins: 500 } } }); const profile = await app.inject({ method: 'POST', url: '/api/profile', headers, payload: { playerId: 'player-b', stats: { wins: 500 } } });