feat: add persistent unique tags to player nicknames

This commit is contained in:
2026-10-26 18:15:29 +09:00
parent fc45534c7d
commit 794cc4da04
8 changed files with 77 additions and 16 deletions
+1
View File
@@ -1,5 +1,6 @@
export const PROFILE_NAME_MIN_LENGTH = 2; export const PROFILE_NAME_MIN_LENGTH = 2;
export const PROFILE_NAME_MAX_LENGTH = 16; export const PROFILE_NAME_MAX_LENGTH = 16;
export const PROFILE_NAME_TAG_DIGITS = 4;
export const PROFILE_NAME_BLOCKED_WORDS = [ export const PROFILE_NAME_BLOCKED_WORDS = [
'시발', '씨발', '씨팔', '시팔', '씨바', '씨벌', '씨부랄', '씹', '좆', '좃', '존나', '시발', '씨발', '씨팔', '시팔', '씨바', '씨벌', '씨부랄', '씹', '좆', '좃', '존나',
'병신', '븅신', '빙신', '개새끼', '새끼', '개새기', '개색기', '개색끼', '지랄', '염병', '병신', '븅신', '빙신', '개새끼', '새끼', '개새기', '개색기', '개색끼', '지랄', '염병',
+7 -3
View File
@@ -2,7 +2,7 @@ import { COSMETIC_COLORS, COSMETIC_EQUIPMENT, EYEWEAR_IDS, HAIR_STYLES, SKIN_TON
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 { PROFILE_NAME_MAX_LENGTH } from '../../constants/profile-name.js';
import { GAME_API_MESSAGES } from '../../constants/recording.js'; import { GAME_API_MESSAGES } from '../../constants/recording.js';
import { profileNameError } from '../shared/profile-name.js'; import { formatProfileName, profileNameError } from '../shared/profile-name.js';
import { appearanceKey, normalizeAppearance, type FighterAppearance } from '../shared/cosmetics.js'; import { appearanceKey, normalizeAppearance, type FighterAppearance } from '../shared/cosmetics.js';
import type { Leaderboard, PlayerProfile, RankedStatus } from '../shared/ranked-types.js'; import type { Leaderboard, PlayerProfile, RankedStatus } from '../shared/ranked-types.js';
import type { ActionId } from '../shared/types.js'; import type { ActionId } from '../shared/types.js';
@@ -23,6 +23,8 @@ interface LobbyOptions {
export function createLobbyFeatures(options: LobbyOptions) { export function createLobbyFeatures(options: LobbyOptions) {
const { api } = options; const { api } = options;
const actions = document.querySelector('.home-actions')!; const actions = document.querySelector('.home-actions')!;
const profileName = document.createElement('p'); profileName.id = 'profile-name'; profileName.hidden = true;
actions.after(profileName);
const rankedStart = document.createElement('button'); const rankedStart = document.createElement('button');
rankedStart.id = 'ranked-start'; rankedStart.className = 'primary'; rankedStart.disabled = true; rankedStart.id = 'ranked-start'; rankedStart.className = 'primary'; rankedStart.disabled = true;
rankedStart.innerHTML = '<span>랭킹 대전</span><span aria-hidden="true">→</span>'; rankedStart.innerHTML = '<span>랭킹 대전</span><span aria-hidden="true">→</span>';
@@ -93,6 +95,8 @@ export function createLobbyFeatures(options: LobbyOptions) {
function updateProfile(next: PlayerProfile) { function updateProfile(next: PlayerProfile) {
const appearanceChanged = !profile || appearanceKey(profile.appearance) !== appearanceKey(next.appearance); const appearanceChanged = !profile || appearanceKey(profile.appearance) !== appearanceKey(next.appearance);
profile = next; profile = next;
profileName.textContent = next.displayName ? formatProfileName(next) : '';
profileName.hidden = !next.displayName;
if (appearanceChanged) { if (appearanceChanged) {
options.onAppearance(next.appearance); options.onAppearance(next.appearance);
select(queue, '.queue-fighter').innerHTML = fighterArt(0, false, next.appearance); select(queue, '.queue-fighter').innerHTML = fighterArt(0, false, next.appearance);
@@ -190,7 +194,7 @@ export function createLobbyFeatures(options: LobbyOptions) {
} catch (error) { if (revision === profileRevision) profileNotice.textContent = error instanceof Error ? error.message : '프로필을 불러오지 못했습니다.'; } } catch (error) { if (revision === profileRevision) profileNotice.textContent = error instanceof Error ? error.message : '프로필을 불러오지 못했습니다.'; }
}; };
leaderboardOpen.onclick = async () => { leaderboardOpen.onclick = async () => {
select(leaderboardDialog, '.ranking-self').textContent = profile ? `내 이름 · ${profile.displayName}` : ''; select(leaderboardDialog, '.ranking-self').textContent = profile ? `내 이름 · ${formatProfileName(profile)}` : '';
const content = select(leaderboardDialog, '.leaderboard-content'); content.textContent = '불러오는 중'; leaderboardDialog.showModal(); const content = select(leaderboardDialog, '.leaderboard-content'); content.textContent = '불러오는 중'; leaderboardDialog.showModal();
try { try {
const board = await api.request<Leaderboard>('/api/ranked/leaderboard'); const board = await api.request<Leaderboard>('/api/ranked/leaderboard');
@@ -203,7 +207,7 @@ export function createLobbyFeatures(options: LobbyOptions) {
for (const entry of rows) { for (const entry of rows) {
const row = document.createElement('tr'); const row = document.createElement('tr');
if (entry.playerId === profile?.playerId) row.className = 'self-ranking'; if (entry.playerId === profile?.playerId) row.className = 'self-ranking';
for (const text of [entry.rank, entry.displayName + (entry.playerId === profile?.playerId ? ' (나)' : ''), entry.best, entry.wins]) { for (const text of [entry.rank, formatProfileName(entry) + (entry.playerId === profile?.playerId ? ' (나)' : ''), entry.best, entry.wins]) {
const cell = document.createElement('td'); cell.textContent = String(text); row.append(cell); const cell = document.createElement('td'); cell.textContent = String(text); row.append(cell);
} }
table.tBodies[0].append(row); table.tBodies[0].append(row);
+18 -4
View File
@@ -18,7 +18,7 @@ import { createEmotes } from '../shared/emotes.js';
import { profileNameError } from '../shared/profile-name.js'; import { profileNameError } from '../shared/profile-name.js';
interface ProfileDocument { interface ProfileDocument {
_id: string; displayName: string; appearance: FighterAppearance; environment: string; createdAt: Date; updatedAt: Date; _id: string; displayName: string; nameTag?: number; appearance: FighterAppearance; environment: string; createdAt: Date; updatedAt: Date;
} }
interface TerminalMatch { interface TerminalMatch {
_id: string; playerIds: Pair<string>; appearances: Pair<FighterAppearance>; winnerIndex: 0 | 1 | null; _id: string; playerIds: Pair<string>; appearances: Pair<FighterAppearance>; winnerIndex: 0 | 1 | null;
@@ -38,6 +38,10 @@ const emptyStats = (): ModeStats => ({ wins: 0, losses: 0, draws: 0, current: 0,
// One service owns the queue and all match clocks in the established single-process server. // One service owns the queue and all match clocks in the established single-process server.
export async function createRankedService(db: Db, clock: Clock = systemClock, environment = 'production', onError?: (code: string) => void) { export async function createRankedService(db: Db, clock: Clock = systemClock, environment = 'production', onError?: (code: string) => void) {
const profiles = db.collection<ProfileDocument>('player_profiles'); const profiles = db.collection<ProfileDocument>('player_profiles');
const counters = db.collection<{ _id: string; value: number }>('profile_counters');
await profiles.createIndex({ nameTag: 1 }, { unique: true, sparse: true });
const highestTag = (await profiles.find({ nameTag: { $exists: true } }).sort({ nameTag: -1 }).limit(1).next())?.nameTag ?? 0;
await counters.updateOne({ _id: 'nameTag' }, { $max: { value: highestTag } }, { upsert: true });
const results = db.collection<TerminalMatch>('ranked_matches'); const results = db.collection<TerminalMatch>('ranked_matches');
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;
@@ -50,6 +54,14 @@ export async function createRankedService(db: Db, clock: Clock = systemClock, en
selves: Map<string, LeaderboardEntry | undefined>; complete: boolean } | undefined; selves: Map<string, LeaderboardEntry | undefined>; complete: boolean } | undefined;
let leaderboardRead: Promise<LeaderboardEntry[]> | undefined; let leaderboardRead: Promise<LeaderboardEntry[]> | undefined;
let tail: Promise<unknown> = Promise.resolve(), closed = false; let tail: Promise<unknown> = Promise.resolve(), closed = false;
async function ensureNameTag(profile: ProfileDocument): Promise<number> {
if (profile.nameTag !== undefined) return profile.nameTag;
const counter = (await counters.findOneAndUpdate({ _id: 'nameTag' }, { $inc: { value: 1 } }, { returnDocument: 'after' }))!;
// Concurrent reads may reserve unused numbers, but only the first assignment wins.
const assigned = await profiles.findOneAndUpdate({ _id: profile._id, nameTag: { $exists: false } },
{ $set: { nameTag: counter.value } }, { returnDocument: 'after' });
return assigned?.nameTag ?? (await profiles.findOne({ _id: profile._id }))!.nameTag!;
}
function serial<T>(work: () => Promise<T>): Promise<T> { function serial<T>(work: () => Promise<T>): Promise<T> {
const next = tail.then(() => { const next = tail.then(() => {
if (closed) throw new ApiError(503, 'RANKED_CLOSED'); if (closed) throw new ApiError(503, 'RANKED_CLOSED');
@@ -118,9 +130,10 @@ export async function createRankedService(db: Db, clock: Clock = systemClock, en
for await (const result of results.find().sort({ order: 1, _id: 1 })) for (const id of result.playerIds) { for await (const result of results.find().sort({ order: 1, _id: 1 })) for (const id of result.playerIds) {
const stats = totals.get(id) ?? emptyStats(); addResult(stats, result, id); totals.set(id, stats); const stats = totals.get(id) ?? emptyStats(); addResult(stats, result, id); totals.set(id, stats);
} }
const entries = (await profiles.find({ _id: { $in: [...totals.keys()] } }).toArray()).map(profile => ({ playerId: profile._id, const entries = (await Promise.all((await profiles.find({ _id: { $in: [...totals.keys()] } }).toArray()).map(async profile => ({ playerId: profile._id,
nameTag: await ensureNameTag(profile),
displayName: profileNameError(profile.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
// under steady saves. The next request refreshes, independent of gameplay. // under steady saves. The next request refreshes, independent of gameplay.
@@ -138,8 +151,9 @@ export async function createRankedService(db: Db, clock: Clock = systemClock, en
appearance: { ...DEFAULT_APPEARANCE }, environment, createdAt: now, updatedAt: now, appearance: { ...DEFAULT_APPEARANCE }, environment, createdAt: now, updatedAt: now,
} }, { upsert: true }); } }, { upsert: true });
const doc = (await profiles.findOne({ _id: playerId }))!; const doc = (await profiles.findOne({ _id: playerId }))!;
const nameTag = await ensureNameTag(doc);
const stats = await readStats(playerId); const stats = await readStats(playerId);
return { playerId, displayName: profileNameError(doc.displayName) ? '' : doc.displayName, appearance: normalizeAppearance(doc.appearance), stats, return { playerId, displayName: profileNameError(doc.displayName) ? '' : doc.displayName, nameTag, appearance: normalizeAppearance(doc.appearance), stats,
unlockedColors: availableColorIds(stats), unlockedEquipment: availableEquipmentIds(stats) }; unlockedColors: availableColorIds(stats), unlockedEquipment: availableEquipmentIds(stats) };
} }
async function persist(match: RankedMatch) { async function persist(match: RankedMatch) {
+5 -1
View File
@@ -1,4 +1,8 @@
import { PROFILE_NAME_BLOCKED_WORDS, PROFILE_NAME_MAX_LENGTH, PROFILE_NAME_MIN_LENGTH } from '../../constants/profile-name.js'; 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. // 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 comparisonName = (name: string) => name.normalize('NFKD').toLowerCase().replace(/[^\p{L}]/gu, '');
+2
View File
@@ -6,6 +6,7 @@ import type { EquipmentId } from '../../constants/cosmetics.js';
export interface PlayerProfile { export interface PlayerProfile {
playerId: string; playerId: string;
displayName: string; displayName: string;
nameTag: number;
appearance: FighterAppearance; appearance: FighterAppearance;
stats: ModeStats; stats: ModeStats;
unlockedColors: string[]; unlockedColors: string[];
@@ -21,6 +22,7 @@ export interface LeaderboardEntry {
rank: number; rank: number;
playerId: string; playerId: string;
displayName: string; displayName: string;
nameTag: number;
wins: number; wins: number;
best: number; best: number;
} }
+12 -7
View File
@@ -44,7 +44,7 @@ test('first visit waits for a saved nickname and keeps the form open on rejectio
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 });
t.after(() => { if (original) Object.defineProperty(globalThis, 'document', original); else Reflect.deleteProperty(globalThis, 'document'); }); t.after(() => { if (original) Object.defineProperty(globalThis, 'document', original); else Reflect.deleteProperty(globalThis, 'document'); });
const profile: PlayerProfile = { playerId: 'a', displayName: '', appearance: normalizeAppearance(undefined), 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: [] }; stats: { wins: 0, losses: 0, draws: 0, current: 0, best: 0 }, unlockedColors: ['white'], unlockedEquipment: [] };
let writes = 0, initialized = false, rejectSave = true; let writes = 0, initialized = false, rejectSave = true;
const api = { async request(_path: string, method?: string, body?: { displayName: string }) { const api = { async request(_path: string, method?: string, body?: { displayName: string }) {
@@ -70,6 +70,11 @@ test('first visit waits for a saved nickname and keeps the form open on rejectio
assert.equal(initialized, false); assert.equal(dialog.querySelector('p').textContent, '저장 실패'); assert.equal(initialized, false); assert.equal(dialog.querySelector('p').textContent, '저장 실패');
rejectSave = false; await submit(); await loading; rejectSave = false; await submit(); await loading;
assert.equal(initialized, true); assert.equal(dialog.open, false); 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 => { test('ranked confirmation shows accept, waits for opponent and cancels by offer id', async t => {
@@ -77,7 +82,7 @@ test('ranked confirmation shows accept, waits for opponent and cancels by offer
Object.defineProperty(globalThis, 'document', { configurable: true, value: document }); Object.defineProperty(globalThis, 'document', { configurable: true, value: document });
t.after(() => { if (original) Object.defineProperty(globalThis, 'document', original); else Reflect.deleteProperty(globalThis, 'document'); }); t.after(() => { if (original) Object.defineProperty(globalThis, 'document', original); else Reflect.deleteProperty(globalThis, 'document'); });
t.mock.timers.enable({ apis: ['setTimeout'] }); t.mock.timers.enable({ apis: ['setTimeout'] });
const profile: PlayerProfile = { playerId: 'a', displayName: 'A', appearance: normalizeAppearance(undefined), 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: [] }; stats: { wins: 0, losses: 0, draws: 0, current: 0, best: 0 }, unlockedColors: ['white'], unlockedEquipment: [] };
let accepted = false, screen = '', cancelBody: unknown; let accepted = false, screen = '', cancelBody: unknown;
const api = { async request(path: string, method?: string, body?: unknown) { const api = { async request(path: string, method?: string, body?: unknown) {
@@ -104,7 +109,7 @@ test('a delayed accept response cannot overwrite a restored newer offer', async
Object.defineProperty(globalThis, 'document', { configurable: true, value: document }); Object.defineProperty(globalThis, 'document', { configurable: true, value: document });
t.after(() => { if (original) Object.defineProperty(globalThis, 'document', original); else Reflect.deleteProperty(globalThis, 'document'); }); t.after(() => { if (original) Object.defineProperty(globalThis, 'document', original); else Reflect.deleteProperty(globalThis, 'document'); });
t.mock.timers.enable({ apis: ['setTimeout'] }); t.mock.timers.enable({ apis: ['setTimeout'] });
const profile: PlayerProfile = { playerId: 'a', displayName: 'A', appearance: normalizeAppearance(undefined), 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: [] }; stats: { wins: 0, losses: 0, draws: 0, current: 0, best: 0 }, unlockedColors: ['white'], unlockedEquipment: [] };
let currentId = 'old', finishAccept!: (value: unknown) => void; let currentId = 'old', finishAccept!: (value: unknown) => void;
const pendingAccept = new Promise(resolve => { finishAccept = resolve; }); const pendingAccept = new Promise(resolve => { finishAccept = resolve; });
@@ -130,7 +135,7 @@ test('an old confirmation poll cannot restore the prompt after accepting starts
Object.defineProperty(globalThis, 'document', { configurable: true, value: document }); Object.defineProperty(globalThis, 'document', { configurable: true, value: document });
t.after(() => { if (original) Object.defineProperty(globalThis, 'document', original); else Reflect.deleteProperty(globalThis, 'document'); }); t.after(() => { if (original) Object.defineProperty(globalThis, 'document', original); else Reflect.deleteProperty(globalThis, 'document'); });
t.mock.timers.enable({ apis: ['setTimeout'] }); t.mock.timers.enable({ apis: ['setTimeout'] });
const profile: PlayerProfile = { playerId: 'a', displayName: 'A', appearance: normalizeAppearance(undefined), 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: [] }; 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 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 } }); const engine = createMatchEngine({ matchId: 'offer-a', mode: 'friend', selfIndex: 0, clock: { now: () => 0 } });
@@ -166,7 +171,7 @@ for (const initiallyQueued of [false, true]) test(`ranked introduction follows s
const engine = createMatchEngine({ matchId: 'ranked-intro', mode: 'friend', selfIndex: 0, clock: { now: () => now } }); const engine = createMatchEngine({ matchId: 'ranked-intro', mode: 'friend', selfIndex: 0, clock: { now: () => now } });
engine.start(matchIntroductionDuration(DEFAULT_GLOBAL_SETTINGS, true)); engine.start(matchIntroductionDuration(DEFAULT_GLOBAL_SETTINGS, true));
const profile: PlayerProfile = { const profile: PlayerProfile = {
playerId: 'player-a', displayName: 'Alpha', appearance: normalizeAppearance(undefined), playerId: 'player-a', displayName: 'Alpha', nameTag: 1, appearance: normalizeAppearance(undefined),
stats: { wins: 0, losses: 0, draws: 0, current: 0, best: 0 }, unlockedColors: ['white'], unlockedEquipment: [], stats: { wins: 0, losses: 0, draws: 0, current: 0, best: 0 }, unlockedColors: ['white'], unlockedEquipment: [],
}; };
const screens: string[] = []; const screens: string[] = [];
@@ -213,7 +218,7 @@ test('a profile read started before equip cannot undo the saved appearance', asy
else Reflect.deleteProperty(globalThis, 'document'); else Reflect.deleteProperty(globalThis, 'document');
}); });
const profile = (skinTone: 'light' | 'tan'): PlayerProfile => ({ const profile = (skinTone: 'light' | 'tan'): PlayerProfile => ({
playerId: 'player-a', displayName: 'Alpha', appearance: normalizeAppearance({ characterId: 0, skinTone, colorId: 'white' }), playerId: 'player-a', displayName: 'Alpha', nameTag: 1, appearance: normalizeAppearance({ characterId: 0, skinTone, colorId: 'white' }),
stats: { wins: 0, losses: 0, draws: 0, current: 0, best: 0 }, unlockedColors: ['white'], unlockedEquipment: [], stats: { wins: 0, losses: 0, draws: 0, current: 0, best: 0 }, unlockedColors: ['white'], unlockedEquipment: [],
}); });
let finishOldRead!: (value: PlayerProfile) => void; let finishOldRead!: (value: PlayerProfile) => void;
@@ -267,7 +272,7 @@ for (const editedDuringLoad of [false, true]) {
const cached = normalizeAppearance({ characterId: 0, colorId: 'white' }); const cached = normalizeAppearance({ characterId: 0, colorId: 'white' });
const remote = normalizeAppearance({ characterId: 1, colorId: 'white', equipment: ['shoes'] }); const remote = normalizeAppearance({ characterId: 1, colorId: 'white', equipment: ['shoes'] });
const profile = (appearance: FighterAppearance): PlayerProfile => ({ const profile = (appearance: FighterAppearance): PlayerProfile => ({
playerId: 'player-a', displayName: 'Alpha', appearance, playerId: 'player-a', displayName: 'Alpha', nameTag: 1, appearance,
stats: { wins: 5, losses: 0, draws: 0, current: 5, best: 5 }, stats: { wins: 5, losses: 0, draws: 0, current: 5, best: 5 },
unlockedColors: ['white'], unlockedEquipment: ['shoes', 'wristbands'], unlockedColors: ['white'], unlockedEquipment: ['shoes', 'wristbands'],
}); });
+8 -1
View File
@@ -1,6 +1,13 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import { profileNameError } from '../src/shared/profile-name.js'; 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', () => { 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 ['손오공', '무도가', '구름-1234', 'ClassMaster', '별빛 전사']) assert.equal(profileNameError(name), undefined, name);
+24
View File
@@ -156,6 +156,27 @@ test('bot and friend wins never change ranked standings, profile stats or cosmet
} finally { await f.close(); } } finally { await f.close(); }
}); });
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 () => { 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 {
@@ -200,6 +221,7 @@ test('startup preserves old names and hides invalid leaderboard names without re
const profiles = f.db.collection<any>('player_profiles'); const profiles = f.db.collection<any>('player_profiles');
await profiles.updateOne({ _id: 'player-a' }, { $set: { displayName: '광고.example' }, $unset: { nameVersion: '' } }); await profiles.updateOne({ _id: 'player-a' }, { $set: { displayName: '광고.example' }, $unset: { nameVersion: '' } });
await profiles.updateOne({ _id: 'player-b' }, { $set: { displayName: '무도가', nameVersion: 0 } }); await profiles.updateOne({ _id: 'player-b' }, { $set: { displayName: '무도가', nameVersion: 0 } });
await profiles.updateMany({}, { $unset: { nameTag: '' } });
const restarted = await createRankedService(f.db, f.clock, 'test'); const restarted = await createRankedService(f.db, f.clock, 'test');
let names: string[]; let names: string[];
try { try {
@@ -207,6 +229,8 @@ test('startup preserves old names and hides invalid leaderboard names without re
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);
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.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');