feat: replace generated nicknames with filtered user input
This commit is contained in:
@@ -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;
|
||||||
@@ -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;
|
|
||||||
|
|||||||
@@ -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: '아직 획득하지 않은 장비입니다.',
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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!);
|
||||||
|
|||||||
@@ -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';
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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
@@ -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 } } });
|
||||||
|
|||||||
Reference in New Issue
Block a user