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
+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');
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),
const profile: PlayerProfile = { playerId: 'a', displayName: '', nameTag: 1, appearance: normalizeAppearance(undefined),
stats: { wins: 0, losses: 0, draws: 0, current: 0, best: 0 }, unlockedColors: ['white'], unlockedEquipment: [] };
let writes = 0, initialized = false, rejectSave = true;
const api = { async request(_path: string, method?: string, body?: { displayName: string }) {
@@ -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, '저장 실패');
rejectSave = false; await submit(); await loading;
assert.equal(initialized, true); assert.equal(dialog.open, false);
assert.equal(document.getElementById('profile-name').textContent, '손오공#0001');
await document.getElementById('nickname-open').onclick!();
assert.equal(dialog.querySelector('input').value, '손오공', 'the tag is not part of the editable nickname');
dialog.querySelector('input').value = '베지터'; await submit();
assert.equal(document.getElementById('profile-name').textContent, '베지터#0001');
});
test('ranked confirmation shows accept, waits for opponent and cancels by offer id', async t => {
@@ -77,7 +82,7 @@ test('ranked confirmation shows accept, waits for opponent and cancels by offer
Object.defineProperty(globalThis, 'document', { configurable: true, value: document });
t.after(() => { if (original) Object.defineProperty(globalThis, 'document', original); else Reflect.deleteProperty(globalThis, 'document'); });
t.mock.timers.enable({ apis: ['setTimeout'] });
const profile: PlayerProfile = { playerId: 'a', displayName: 'A', 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: [] };
let accepted = false, screen = '', cancelBody: 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 });
t.after(() => { if (original) Object.defineProperty(globalThis, 'document', original); else Reflect.deleteProperty(globalThis, 'document'); });
t.mock.timers.enable({ apis: ['setTimeout'] });
const profile: PlayerProfile = { playerId: 'a', displayName: 'A', 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: [] };
let currentId = 'old', finishAccept!: (value: unknown) => void;
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 });
t.after(() => { if (original) Object.defineProperty(globalThis, 'document', original); else Reflect.deleteProperty(globalThis, 'document'); });
t.mock.timers.enable({ apis: ['setTimeout'] });
const profile: PlayerProfile = { playerId: 'a', displayName: 'A', 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: [] };
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 } });
@@ -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 } });
engine.start(matchIntroductionDuration(DEFAULT_GLOBAL_SETTINGS, true));
const profile: PlayerProfile = {
playerId: 'player-a', displayName: 'Alpha', appearance: normalizeAppearance(undefined),
playerId: 'player-a', displayName: 'Alpha', nameTag: 1, appearance: normalizeAppearance(undefined),
stats: { wins: 0, losses: 0, draws: 0, current: 0, best: 0 }, unlockedColors: ['white'], unlockedEquipment: [],
};
const screens: string[] = [];
@@ -213,7 +218,7 @@ test('a profile read started before equip cannot undo the saved appearance', asy
else Reflect.deleteProperty(globalThis, 'document');
});
const profile = (skinTone: 'light' | 'tan'): PlayerProfile => ({
playerId: 'player-a', displayName: 'Alpha', appearance: normalizeAppearance({ characterId: 0, skinTone, colorId: 'white' }),
playerId: 'player-a', displayName: 'Alpha', nameTag: 1, appearance: normalizeAppearance({ characterId: 0, skinTone, colorId: 'white' }),
stats: { wins: 0, losses: 0, draws: 0, current: 0, best: 0 }, unlockedColors: ['white'], unlockedEquipment: [],
});
let finishOldRead!: (value: PlayerProfile) => void;
@@ -267,7 +272,7 @@ for (const editedDuringLoad of [false, true]) {
const cached = normalizeAppearance({ characterId: 0, colorId: 'white' });
const remote = normalizeAppearance({ characterId: 1, colorId: 'white', equipment: ['shoes'] });
const profile = (appearance: FighterAppearance): PlayerProfile => ({
playerId: 'player-a', displayName: 'Alpha', appearance,
playerId: 'player-a', displayName: 'Alpha', nameTag: 1, appearance,
stats: { wins: 5, losses: 0, draws: 0, current: 5, best: 5 },
unlockedColors: ['white'], unlockedEquipment: ['shoes', 'wristbands'],
});
+8 -1
View File
@@ -1,6 +1,13 @@
import assert from 'node:assert/strict';
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', () => {
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(); }
});
test('duplicate nicknames receive distinct persistent tags despite concurrent reads and client tag spoofing', integration, async () => {
const f = await fixture();
try {
const reads = await Promise.all(Array.from({ length: 8 }, () => f.service.profile('player-a')));
assert.ok(reads[0].nameTag > 0);
assert.equal(new Set(reads.map(profile => profile.nameTag)).size, 1);
const a = await f.service.updateProfile('player-a', { displayName: '손오공' });
const b = await f.service.updateProfile('player-b', { displayName: '손오공', nameTag: a.nameTag } as any);
assert.equal(a.displayName, b.displayName); assert.notEqual(a.nameTag, b.nameTag);
assert.equal((await f.service.updateProfile('player-a', { displayName: '베지터', nameTag: b.nameTag } as any)).nameTag, a.nameTag);
await assert.rejects(f.db.collection<any>('player_profiles').updateOne({ _id: 'player-b' }, { $set: { nameTag: a.nameTag } }), { code: 11000 });
await f.service.close();
const restarted = await createRankedService(f.db, f.clock, 'test');
try {
assert.equal((await restarted.profile('player-a')).nameTag, a.nameTag);
assert.equal((await restarted.profile('player-b')).nameTag, b.nameTag);
assert.ok((await restarted.profile('player-c')).nameTag > Math.max(a.nameTag, b.nameTag));
} finally { await restarted.close(); }
} finally { await f.close(); }
});
test('profiles save filtered names and validate unlocked appearances, persistence and pairing snapshots', integration, async () => {
const f = await fixture();
try {
@@ -200,6 +221,7 @@ test('startup preserves old names and hides invalid leaderboard names without re
const profiles = f.db.collection<any>('player_profiles');
await profiles.updateOne({ _id: 'player-a' }, { $set: { displayName: '광고.example' }, $unset: { nameVersion: '' } });
await profiles.updateOne({ _id: 'player-b' }, { $set: { displayName: '무도가', nameVersion: 0 } });
await profiles.updateMany({}, { $unset: { nameTag: '' } });
const restarted = await createRankedService(f.db, f.clock, 'test');
let names: string[];
try {
@@ -207,6 +229,8 @@ test('startup preserves old names and hides invalid leaderboard names without re
const board = await restarted.leaderboard('new-viewer');
names = board.entries.map(entry => entry.displayName);
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.equal(board.entries[0].wins, 1);
const profile = await restarted.profile('player-a');