Files
ssessesse-ki-battle/tests/ranked.test.ts
T

840 lines
50 KiB
TypeScript

import { DEFAULT_GLOBAL_SETTINGS } from '../constants/global-settings.js';
import assert from 'node:assert/strict';
import test from 'node:test';
import { randomBytes, randomUUID } from 'node:crypto';
import Fastify from 'fastify';
import { MongoClient, type Db } from 'mongodb';
import { MATCH_COUNTDOWN_MS, REVEAL_DURATION_MS, TURN_DURATION_MS } from '../constants/gameplay.js';
import { RANKED_FINISHED_TTL_MS, RANKED_QUEUE_TTL_MS, RANKED_STATS_CACHE_LIMIT, RANKED_STATS_CACHE_TTL_MS,
RANKED_LEADERBOARD_CACHE_LIMIT, RANKED_LEADERBOARD_CACHE_TTL_MS } from '../constants/ranked.js';
import { createRankedService } from '../src/server/ranked-service.js';
import { registerRankedApi } from '../src/server/ranked-api.js';
import { createGameSessionStore } from '../src/server/game-session-store.js';
import { registerGameApi } from '../src/server/game-api.js';
import { ManualClock } from './helpers.js';
import { normalizeAppearance } from '../src/shared/cosmetics.js';
import { EQUIPMENT_IDS } from '../constants/cosmetics.js';
import { createRoomStore } from '../src/server/rooms.js';
const integration = { skip: !process.env.TEST_MONGODB_URI };
test('orphan cleanup unblocks the same player without blocking independent browsers or live tabs', integration, async () => {
const f = await fixture();
try {
await f.sessions.register(f.a, { requestId: 'bot', matchId: 'bot', mode: 'bot', expectedPreviousSeq: 0 });
assert.equal((await f.service.join('player-b', f.b.gameSessionId)).status, 'queued');
await f.service.cancel('player-b', f.b.gameSessionId);
await assert.rejects(f.service.join('player-a', f.a2.gameSessionId), /MATCH_PENDING/);
f.clock.time += 90000; await f.sessions.touchPresence(f.a);
f.clock.time += 31000; await f.sessions.cleanupDisconnectedMatches();
await assert.rejects(f.service.join('player-a', f.a2.gameSessionId), /MATCH_PENDING/);
f.clock.time += 90000; await f.sessions.cleanupDisconnectedMatches();
assert.equal((await f.service.join('player-a', f.a2.gameSessionId)).status, 'queued');
} finally { await f.close(); }
});
async function pairAndAccept(service: Awaited<ReturnType<typeof createRankedService>>, sessionA: string, sessionB: string) {
const paired = await service.join('player-b', sessionB);
assert.equal(paired.status, 'confirming');
await service.accept('player-a', sessionA, paired.offer!.matchId);
return service.accept('player-b', sessionB, paired.offer!.matchId);
}
test('ranked emotes authenticate membership, reach both players and preserve hidden actions', integration, async () => {
const f = await fixture();
try {
await f.service.join('player-a', f.a.gameSessionId);
const paired = await pairAndAccept(f.service, f.a.gameSessionId, f.b.gameSessionId);
const matchId = paired.match!.matchId;
await f.clock.advance(paired.match!.startsAt - f.clock.now());
await f.service.action('player-a', f.a.gameSessionId, matchId, 1, 'charge');
const before = await f.service.status('player-b', f.b.gameSessionId);
const sent = await f.service.emote('player-a', f.a.gameSessionId, matchId, 'greet');
const received = await f.service.status('player-b', f.b.gameSessionId);
assert.equal(received.match!.emotes?.[0]?.id, 'greet');
assert.ok(received.match!.version > before.match!.version, 'an older poll cannot overwrite this emote');
assert.deepEqual(received.match!.emotes, sent.match!.emotes);
assert.equal(received.match!.ownAction, null);
assert.deepEqual(received.match!.energy, [0, 0]);
await assert.rejects(f.service.emote('player-a', f.a.gameSessionId, matchId, 'thanks'), /INVALID_REQUEST/);
await assert.rejects(f.service.emote('player-b', f.b.gameSessionId, matchId, 'arbitrary text'), /INVALID_REQUEST/);
await assert.rejects(f.service.emote('player-a', f.b.gameSessionId, matchId, 'thanks'), /INVALID_SESSION/);
await assert.rejects(f.service.emote('player-b', f.b.gameSessionId, 'other-match', 'thanks'), /NO_ACTIVE_MATCH/);
} finally { await f.close(); }
});
function observeResultReads(db: Db, afterRead: (filter?: Record<string, unknown>) => Promise<void>) {
const records = db.collection('ranked_matches');
return new Proxy(db, { get(target, key) {
if (key === 'collection') return (name: string) => name !== 'ranked_matches' ? target.collection(name) : new Proxy(records, {
get(collection, method) {
if (method === 'find') return (filter?: Record<string, unknown>) => {
const cursor = collection.find(filter ?? {});
const iterate = cursor[Symbol.asyncIterator].bind(cursor);
cursor[Symbol.asyncIterator] = async function* () {
const snapshot = [];
for await (const result of iterate()) snapshot.push(result);
await afterRead(filter);
yield* snapshot;
};
return cursor;
};
const value = Reflect.get(collection, method);
return typeof value === 'function' ? value.bind(collection) : value;
},
});
const value = Reflect.get(target, key);
return typeof value === 'function' ? value.bind(target) : value;
} });
}
async function fixture() {
const client = await new MongoClient(process.env.TEST_MONGODB_URI!, { serverSelectionTimeoutMS: 5000 }).connect();
const db = client.db(`dragon_ball_test_ranked_${randomUUID().replaceAll('-', '')}`);
const clock = new ManualClock();
const sessions = await createGameSessionStore(db, clock.now, 'test');
const service = await createRankedService(db, clock, 'test');
const a = await sessions.create(randomBytes(32).toString('base64url'), 'player-a');
const a2 = await sessions.create(randomBytes(32).toString('base64url'), 'player-a');
const b = await sessions.create(randomBytes(32).toString('base64url'), 'player-b');
return { db, clock, sessions, service, a, a2, b,
async close() { await service.close(); try { await db.dropDatabase(); } finally { await client.close(); } },
};
}
test('ranked pairing is serialized by player identity across tabs and survives reload', integration, async () => {
const f = await fixture();
try {
const { service: s, a, a2, b } = f;
const first = await Promise.all([s.join('player-a', a.gameSessionId), s.join('player-a', a2.gameSessionId)]);
assert.ok(first.every(value => value.status === 'queued'));
await assert.rejects(s.assertAvailable('player-a'), /MATCH_PENDING/);
const paired = await pairAndAccept(s, a.gameSessionId, b.gameSessionId);
const restored = await s.status('player-a', a2.gameSessionId);
assert.equal(paired.match?.matchId, restored.match?.matchId);
assert.notEqual(paired.match?.selfIndex, restored.match?.selfIndex);
assert.equal(restored.match?.mode, 'friend'); assert.equal(restored.match?.ranked, true);
assert.equal((await s.join('player-a', a.gameSessionId)).match?.matchId, paired.match?.matchId);
assert.equal((await s.cancel('player-a', a.gameSessionId)).status, 'matched');
await assert.rejects(s.status('player-a', b.gameSessionId), /INVALID_SESSION/);
assert.equal((await s.profile('player-a')).stats.wins, 0);
} finally { await f.close(); }
});
test('bot and friend wins never change ranked standings, profile stats or cosmetic rewards', integration, async () => {
const f = await fixture();
try {
await f.service.join('player-a', f.a.gameSessionId);
const rankedId = (await pairAndAccept(f.service, f.a.gameSessionId, f.b.gameSessionId)).match!.matchId;
await f.service.leave('player-b', f.b.gameSessionId, rankedId);
await f.service.leave('player-a', f.a.gameSessionId, rankedId);
const before = await f.service.profile('player-a');
const leaderboard = await f.service.leaderboard('player-a');
const rankedRecords = await f.db.collection('ranked_matches').find().toArray();
assert.equal(before.stats.wins, 1); assert.equal(before.stats.best, 1);
const rooms = await createRoomStore(f.db, f.clock.now);
// Seed only ordinary-session history in this isolated test DB, then record the
// hundredth win through each real result path for the same persistent playerId.
for (const [mode, session] of [['bot', f.a], ['friend', f.a2]] as const) {
await f.db.collection<any>('game_sessions').updateOne({ _id: session.gameSessionId }, { $set: {
[`stats.${mode}`]: { wins: 99, current: 99, best: 99, losses: 0, draws: 0 },
} });
const room = mode === 'friend' ? await rooms.create(session.gameSessionId, randomUUID()) : undefined;
if (room) await rooms.join(f.b.gameSessionId, room.code);
const match = await f.sessions.register(session, { mode, requestId: randomUUID(), expectedPreviousSeq: 0,
matchId: room?.currentMatchId ?? randomUUID(), ...(room ? { roomId: room.id } : {}),
});
const recorded = await f.sessions.record(session, { ...match, outcome: 'win', reason: 'hit' });
assert.equal(recorded.stats[mode].wins, 100); assert.equal(recorded.stats[mode].best, 100);
if (room) await rooms.leave(room.id, session.gameSessionId);
assert.deepEqual(await f.service.profile('player-a'), before);
assert.deepEqual(await f.service.leaderboard('player-a'), leaderboard);
}
assert.deepEqual(await f.db.collection('ranked_matches').find().toArray(), rankedRecords);
assert.deepEqual(before.unlockedEquipment, []); assert.deepEqual(before.unlockedColors, ['white']);
for (const equipment of EQUIPMENT_IDS) {
await assert.rejects(f.service.updateProfile('player-a', { appearance: {
...before.appearance, equipment: [equipment],
} }), (error: any) => error.statusCode === 403 && error.code === 'EQUIPMENT_LOCKED');
}
await assert.rejects(f.service.updateProfile('player-a', { appearance: { ...before.appearance, colorId: 'red' } }), /COLOR_LOCKED/);
} 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 {
const s = f.service;
assert.equal((await s.profile('player-a')).displayName, '');
const original = await s.updateProfile('player-a', { displayName: '손오공' });
assert.deepEqual(original.appearance, normalizeAppearance({ characterId: 0, colorId: 'white' }));
assert.deepEqual(original.unlockedEquipment, []);
assert.equal(original.displayName, '손오공');
for (const displayName of ['씨 발', '병_신', '<img>', null, undefined]) {
await assert.rejects(s.updateProfile('player-a', { displayName } as any), /PROFILE_NAME_PROFANITY|INVALID_PROFILE_NAME/);
}
for (const appearance of [{ characterId: 2, colorId: 'white' }, { characterId: 0, colorId: 'bogus' }, { characterId: 0, colorId: 'orange' }]) {
await assert.rejects(s.updateProfile('player-a', { appearance: appearance as any }), /INVALID_APPEARANCE|COLOR_LOCKED/);
}
const selected = await s.updateProfile('player-a', { appearance: { characterId: 1, colorId: 'white' } });
await s.join('player-a', f.a.gameSessionId);
const paired = await pairAndAccept(s, f.a.gameSessionId, f.b.gameSessionId);
await s.updateProfile('player-a', { appearance: { characterId: 0, colorId: 'white' } });
assert.deepEqual(paired.match?.appearances?.[0], selected.appearance);
assert.equal((await f.db.collection('player_profiles').findOne({ _id: 'player-a' as any }))?.displayName, original.displayName);
await s.close();
const restarted = await createRankedService(f.db, f.clock, 'test');
try {
assert.equal((await restarted.profile('player-a')).displayName, original.displayName);
assert.equal((await restarted.status('player-a', f.a.gameSessionId)).status, 'idle');
assert.equal((await restarted.profile('player-a')).stats.wins, 0);
} finally { await restarted.close(); }
} finally { await f.close(); }
});
test('startup preserves old names and hides invalid leaderboard names without rewriting profiles', integration, async () => {
const f = await fixture();
try {
await f.service.updateProfile('player-a', { appearance: { characterId: 1, colorId: 'white' } });
await f.service.join('player-a', f.a.gameSessionId);
const id = (await pairAndAccept(f.service, f.a.gameSessionId, f.b.gameSessionId)).match!.matchId;
await f.service.leave('player-b', f.b.gameSessionId, id);
const expectedStats = (await f.service.profile('player-a')).stats;
const records = await f.db.collection('ranked_matches').find().toArray();
await f.service.close();
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 {
// Neither old profile is visited before the leaderboard request from a new viewer.
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');
assert.deepEqual(profile.stats, expectedStats);
assert.deepEqual(profile.appearance, normalizeAppearance({ characterId: 1, colorId: 'white', skinTone: 'light', hairStyle: 'black' }));
assert.deepEqual(await f.db.collection('ranked_matches').find().toArray(), records);
assert.equal((await profiles.findOne({ _id: 'player-b' })).nameVersion, 0);
assert.equal((await profiles.findOne({ _id: 'player-a' })).displayName, '광고.example');
assert.equal(profile.displayName, '');
await restarted.updateProfile('player-a', { displayName: '새이름' });
names = (await restarted.leaderboard('new-viewer')).entries.map(entry => entry.displayName);
assert.deepEqual(names, ['새이름', '무도가']);
} finally { await restarted.close(); }
const again = await createRankedService(f.db, f.clock, 'test');
try { assert.deepEqual((await again.leaderboard('new-viewer')).entries.map(entry => entry.displayName), names!); }
finally { await again.close(); }
} finally { await f.close(); }
});
test('hair is free on both characters, malformed cosmetics and unearned equipment are rejected', integration, async () => {
const f = await fixture();
try {
const base = { characterId: 0 as const, colorId: 'white' };
for (const invalid of [{ hairStyle: null }, { hairStyle: 'long' }, { hairStyle: 0 },
{ equipment: null }, { equipment: 'shoes' }, { equipment: ['unknown'] }, { equipment: ['shoes', 'shoes'] }, { equipment: [0] }]) {
await assert.rejects(f.service.updateProfile('player-a', { appearance: { ...base, ...invalid } as any }),
(error: any) => error.code === 'INVALID_APPEARANCE' && error.statusCode === 400);
}
for (const id of EQUIPMENT_IDS) {
await assert.rejects(f.service.updateProfile('player-a', {
appearance: { ...base, equipment: [id] }, stats: { wins: 999, best: 999 }, unlockedEquipment: [...EQUIPMENT_IDS],
} as any), (error: any) => error.code === 'EQUIPMENT_LOCKED' && error.statusCode === 403);
}
const original = await f.service.profile('player-a');
for (const characterId of [0, 1] as const) {
for (const hairStyle of ['black', 'white', 'bald', 'default'] as const) {
const profile = await f.service.updateProfile('player-a', { appearance: { ...base, characterId, hairStyle } });
assert.equal(profile.appearance.hairStyle, hairStyle === 'default' ? characterId === 0 ? 'black' : 'white' : hairStyle);
assert.equal(profile.appearance.characterId, characterId);
assert.deepEqual(profile.appearance.equipment, []); assert.deepEqual(profile.stats, original.stats);
}
}
await f.service.updateProfile('player-a', { appearance: { ...base, hairStyle: 'bald' } });
const legacyUpdate = await f.service.updateProfile('player-a', { appearance: { ...base, characterId: 1 } });
assert.equal(legacyUpdate.appearance.hairStyle, 'bald');
assert.equal(legacyUpdate.displayName, original.displayName);
await f.service.close();
const restarted = await createRankedService(f.db, f.clock, 'test');
try { assert.deepEqual(await restarted.profile('player-a'), legacyUpdate); }
finally { await restarted.close(); }
} finally { await f.close(); }
});
test('equipment rewards use canonical thresholds, toggle independently, persist and snapshot on pairing', integration, async () => {
const f = await fixture();
try {
const s = f.service;
const basicEquipment = ['shoes', 'wristbands', 'headband'] as const;
for (let wins = 1; wins <= 20; wins++) {
await s.join('player-a', f.a.gameSessionId);
const id = (await pairAndAccept(s, f.a.gameSessionId, f.b.gameSessionId)).match!.matchId;
await s.leave('player-b', f.b.gameSessionId, id);
await s.leave('player-a', f.a.gameSessionId, id);
const profile = await s.profile('player-a');
assert.deepEqual(profile.unlockedEquipment, wins < 3 ? [] : wins < 5 ? ['wristbands'] : wins < 20 ? ['shoes', 'wristbands'] : [...basicEquipment]);
}
const base = { characterId: 1 as const, colorId: 'blue', hairStyle: 'bald' as const, skinTone: 'light' as const };
for (let mask = 0; mask < 8; mask++) {
const equipment = basicEquipment.filter((_, index) => mask & (1 << index));
const profile = await s.updateProfile('player-a', { appearance: { ...base, equipment } });
assert.deepEqual(profile.appearance, { ...base, equipment });
}
// A legacy color/character update must not unequip the new fields.
const equipped = await s.updateProfile('player-a', { appearance: { characterId: 1, colorId: 'blue' } });
assert.deepEqual(equipped.appearance, { ...base, equipment: [...basicEquipment] });
const stored = await f.db.collection<any>('player_profiles').findOne({ _id: 'player-a' });
assert.deepEqual(stored.appearance, equipped.appearance);
await s.join('player-a', f.a.gameSessionId);
const paired = await pairAndAccept(s, f.a.gameSessionId, f.b.gameSessionId);
await s.updateProfile('player-a', { appearance: { ...base, hairStyle: 'default', equipment: [] } });
assert.deepEqual(paired.match!.appearances![0], equipped.appearance);
assert.deepEqual((await s.status('player-a', f.a2.gameSessionId)).match!.appearances![0], equipped.appearance);
await s.updateProfile('player-a', { appearance: equipped.appearance });
await s.close();
const restarted = await createRankedService(f.db, f.clock, 'test');
try { assert.deepEqual(await restarted.profile('player-a'), equipped); }
finally { await restarted.close(); }
// Existing profiles lacking the new fields retain earned rewards, color, stats and name.
await f.db.collection<any>('player_profiles').updateOne({ _id: 'player-a' }, {
$unset: { 'appearance.hairStyle': '', 'appearance.equipment': '', 'appearance.skinTone': '' },
});
const migrated = await createRankedService(f.db, f.clock, 'test');
try {
assert.deepEqual(await migrated.profile('player-a'), { ...equipped,
appearance: { characterId: 1, colorId: 'blue', hairStyle: 'white', skinTone: 'tan', equipment: [] },
});
} finally { await migrated.close(); }
} finally { await f.close(); }
});
test('skin and independent white hair validate, preserve legacy profiles, persist and snapshot on pairing', integration, async () => {
const f = await fixture();
try {
const original = await f.service.profile('player-a');
await f.db.collection<any>('player_profiles').updateOne({ _id: 'player-a' }, { $set: {
appearance: { characterId: 1, colorId: 'white', hairStyle: 'default', equipment: [] },
} });
const legacy = await f.service.profile('player-a');
assert.deepEqual(legacy.appearance, { characterId: 1, colorId: 'white', skinTone: 'tan', hairStyle: 'white', equipment: [] });
assert.equal(legacy.displayName, original.displayName); assert.deepEqual(legacy.stats, original.stats);
for (const skinTone of [null, 'invalid', 0]) {
await assert.rejects(f.service.updateProfile('player-a', { appearance: { ...legacy.appearance, skinTone } as any }),
(error: any) => error.code === 'INVALID_APPEARANCE' && error.statusCode === 400);
}
for (const skinTone of ['light', 'tan'] as const) for (const hairStyle of ['black', 'white', 'bald'] as const) {
const changed = await f.service.updateProfile('player-a', { appearance: { ...legacy.appearance, skinTone, hairStyle } });
assert.equal(changed.appearance.characterId, 1);
assert.equal(changed.appearance.skinTone, skinTone); assert.equal(changed.appearance.hairStyle, hairStyle);
}
const selected = await f.service.updateProfile('player-a', { appearance: {
characterId: 0, colorId: 'white', skinTone: 'tan', hairStyle: 'white', equipment: [],
} });
assert.deepEqual((await f.service.updateProfile('player-a', { appearance: { characterId: 0, colorId: 'white' } })).appearance, selected.appearance);
await f.service.join('player-a', f.a.gameSessionId);
const paired = await pairAndAccept(f.service, f.a.gameSessionId, f.b.gameSessionId);
await f.service.updateProfile('player-a', { appearance: { ...selected.appearance, skinTone: 'light', hairStyle: 'black' } });
assert.deepEqual(paired.match!.appearances![0], selected.appearance);
assert.deepEqual((await f.service.status('player-a', f.a2.gameSessionId)).match!.appearances![0], selected.appearance);
await f.service.updateProfile('player-a', { appearance: selected.appearance });
await f.service.close();
const restarted = await createRankedService(f.db, f.clock, 'test');
try { assert.deepEqual(await restarted.profile('player-a'), selected); }
finally { await restarted.close(); }
} finally { await f.close(); }
});
test('ranked eyewear persists after streak loss, rejects locked or dual eyewear, and snapshots on pairing', integration, async () => {
const f = await fixture();
const original = await f.service.profile('player-a');
await f.service.close();
// Canonical historical outcomes exercise aggregation without playing 100 live games.
let order = 0;
const records = [49, 50, 99, 100].flatMap(best => {
const playerId = best === 100 ? 'player-a' : `best-${best}`;
return Array.from({ length: best + 1 }, (_, index) => ({
_id: randomUUID(), playerIds: [playerId, 'historical-opponent'],
appearances: [normalizeAppearance(undefined), normalizeAppearance(undefined)],
winnerIndex: index === best ? 1 : 0, reason: 'hit', order: ++order, completedAt: new Date(f.clock.now()), environment: 'test',
}));
});
await f.db.collection<any>('ranked_matches').insertMany(records);
const s = await createRankedService(f.db, f.clock, 'test');
try {
const base = { characterId: 1 as const, colorId: 'white', hairStyle: 'bald' as const };
for (const best of [49, 50, 99, 100]) {
const playerId = best === 100 ? 'player-a' : `best-${best}`;
const profile = await s.profile(playerId);
assert.equal(profile.stats.best, best); assert.equal(profile.stats.current, 0);
for (const [item, threshold] of [['glasses', 50], ['sunglasses', 100]] as const) {
const update = () => s.updateProfile(playerId, { appearance: { ...base, equipment: [item] } });
assert.equal(profile.unlockedEquipment.includes(item), best >= threshold);
if (best < threshold) await assert.rejects(update(), (error: any) => error.statusCode === 403 && error.code === 'EQUIPMENT_LOCKED');
else assert.deepEqual((await update()).appearance.equipment, [item]);
}
}
await assert.rejects(s.updateProfile('player-a', { appearance: { ...base, equipment: ['sunglasses', 'glasses'] } }),
(error: any) => error.statusCode === 400 && error.code === 'INVALID_APPEARANCE');
const equipped = await s.updateProfile('player-a', { appearance: { ...base, equipment: ['shoes', 'headband', 'sunglasses'] } });
assert.equal(equipped.displayName, original.displayName);
assert.deepEqual((await s.updateProfile('player-a', { appearance: { characterId: 1, colorId: 'white' } })).appearance, equipped.appearance);
await s.join('player-a', f.a.gameSessionId);
const paired = await pairAndAccept(s, f.a.gameSessionId, f.b.gameSessionId);
await s.updateProfile('player-a', { appearance: { ...base, equipment: ['glasses'] } });
assert.deepEqual((await s.status('player-a', f.a2.gameSessionId)).match!.appearances![0], equipped.appearance);
assert.deepEqual(paired.match!.appearances![0], equipped.appearance);
assert.deepEqual((await s.updateProfile('player-a', { appearance: { ...base, equipment: [] } })).appearance.equipment, []);
await s.updateProfile('player-a', { appearance: equipped.appearance });
await s.close();
const restarted = await createRankedService(f.db, f.clock, 'test');
try { assert.deepEqual(await restarted.profile('player-a'), equipped); }
finally { await restarted.close(); }
} finally { await s.close(); await f.close(); }
});
test('server hides choices, checks costs and deadlines, resolves once and awards canonical wins', integration, async () => {
const f = await fixture();
try {
const { service: s, a, a2, b, clock } = f;
await s.join('player-a', a.gameSessionId);
const id = (await pairAndAccept(s, a.gameSessionId, b.gameSessionId)).match!.matchId;
await assert.rejects(s.action('player-a', a.gameSessionId, id, 0, 'charge'), /INVALID_ACTION/);
await clock.advance(MATCH_COUNTDOWN_MS);
await assert.rejects(s.action('player-a', a.gameSessionId, id, 1, 'beam'), /INVALID_ACTION/);
const version = (await s.status('player-a', a.gameSessionId)).match!.version;
const locked = await s.action('player-a', a.gameSessionId, id, 1, 'charge');
assert.ok(locked.match!.version > version);
await assert.rejects(s.action('player-a', a2.gameSessionId, id, 1, 'guard'), /ACTION_LOCKED/);
const hidden = (await s.status('player-b', b.gameSessionId)).match!;
assert.equal(hidden.ownAction, null); assert.equal(hidden.reveal, undefined);
assert.equal(hidden.version, version);
const resolved = await s.action('player-b', b.gameSessionId, id, 1, 'charge');
assert.equal(resolved.match?.phase, 'revealing');
assert.deepEqual((await s.status('player-b', b.gameSessionId)).match?.reveal?.actions, ['charge', 'charge']);
await clock.advance(REVEAL_DURATION_MS);
await s.action('player-a', a.gameSessionId, id, 2, 'blast');
const finished = await s.action('player-b', b.gameSessionId, id, 2, 'charge');
assert.equal(finished.match?.phase, 'finished');
await Promise.all(Array.from({ length: 8 }, () => s.status('player-a', a.gameSessionId)));
const ended = await s.status('player-b', b.gameSessionId);
assert.equal(ended.status, 'matched'); assert.equal(ended.match?.phase, 'finished');
assert.equal(ended.match?.finished?.winnerIndex, 0);
assert.equal(ended.match?.finished?.saved, true);
assert.deepEqual((await s.profile('player-a')).stats, { wins: 1, losses: 0, draws: 0, current: 1, best: 1 });
assert.equal(ended.profile.stats.losses, 1);
assert.deepEqual(ended.match?.finished?.stats.friend, ended.profile.stats);
assert.equal(await f.db.collection('ranked_matches').countDocuments(), 1);
await s.profile('visitor-only');
const board = await s.leaderboard('player-a');
assert.equal(board.entries.length, 2);
assert.equal((await s.leaderboard('visitor-only')).self, undefined);
assert.equal(board.entries[0].playerId, 'player-a'); assert.equal(board.self?.rank, 1);
await assert.rejects(s.action('player-a', a.gameSessionId, id, 2, 'guard'), /INVALID_ACTION/);
await s.leave('player-a', a.gameSessionId, id);
assert.equal((await s.leave('player-a', a2.gameSessionId, id)).status, 'idle');
assert.equal((await s.status('player-b', b.gameSessionId)).status, 'matched');
} finally { await f.close(); }
});
test('finished matches retain results on reload then expire when abandoned', integration, async () => {
const f = await fixture();
try {
await f.service.join('player-a', f.a.gameSessionId);
const id = (await pairAndAccept(f.service, f.a.gameSessionId, f.b.gameSessionId)).match!.matchId;
await f.clock.advance(MATCH_COUNTDOWN_MS + TURN_DURATION_MS * 3);
assert.equal((await f.service.status('player-a', f.a2.gameSessionId)).match?.finished?.reason, 'double_timeout');
await f.clock.advance(RANKED_FINISHED_TTL_MS - 1);
assert.equal((await f.service.status('player-a', f.a2.gameSessionId)).match?.matchId, id);
await f.clock.advance(1);
await f.service.assertAvailable('player-b');
assert.equal((await f.service.status('player-b', f.b.gameSessionId)).status, 'idle');
await assert.rejects(f.service.assertAvailable('player-a'), /MATCH_PENDING/);
await f.clock.advance(RANKED_FINISHED_TTL_MS);
assert.equal((await f.service.status('player-a', f.a.gameSessionId)).status, 'idle');
assert.equal(f.clock.timers.size, 0);
assert.equal((await f.service.profile('player-a')).stats.draws, 1);
assert.equal(await f.db.collection('ranked_matches').countDocuments(), 1);
} finally { await f.close(); }
});
test('deadline rejects late actions and duplicate terminal writes survive an ambiguous database response', integration, async () => {
const f = await fixture();
await f.service.close();
const collection = f.db.collection('ranked_matches');
let loseResponse = true;
const db = new Proxy(f.db, { get(target, key) {
if (key === 'collection') return (name: string) => name !== 'ranked_matches' ? target.collection(name) : new Proxy(collection, {
get(records, method) {
if (method === 'updateOne') return async (...args: Parameters<typeof collection.updateOne>) => {
const result = await records.updateOne(...args);
if (loseResponse) { loseResponse = false; throw new Error('response lost after write'); }
return result;
};
const value = Reflect.get(records, method);
return typeof value === 'function' ? value.bind(records) : value;
},
});
const value = Reflect.get(target, key);
return typeof value === 'function' ? value.bind(target) : value;
} });
const s = await createRankedService(db, f.clock, 'test');
try {
await s.join('player-a', f.a.gameSessionId);
const id = (await pairAndAccept(s, f.a.gameSessionId, f.b.gameSessionId)).match!.matchId;
await f.clock.advance(MATCH_COUNTDOWN_MS);
await s.action('player-a', f.a.gameSessionId, id, 1, 'guard');
assert.equal((await s.leaderboard('player-a')).self, undefined);
// Jump to the deadline without executing timers: the request itself must settle it.
f.clock.time += TURN_DURATION_MS * 3;
await assert.rejects(s.action('player-b', f.b.gameSessionId, id, 1, 'guard'), /response lost after write/);
assert.equal((await s.profile('player-a')).stats.wins, 1);
assert.equal((await s.leaderboard('player-a')).self?.wins, 1);
await assert.rejects(s.action('player-b', f.b.gameSessionId, id, 1, 'guard'), /INVALID_ACTION/);
assert.equal((await s.status('player-b', f.b.gameSessionId)).match?.finished?.reason, 'timeout');
assert.equal(await collection.countDocuments(), 1);
assert.equal((await s.profile('player-a')).stats.wins, 1);
assert.equal((await s.profile('player-b')).stats.losses, 1);
} finally { await s.close(); await f.close(); }
});
test('queue cancel, heartbeat expiry and existing games exclude unavailable players', integration, async () => {
const f = await fixture();
try {
const { service: s, a, b, clock } = f;
await s.join('player-a', a.gameSessionId);
assert.equal((await s.cancel('player-a', a.gameSessionId)).status, 'idle');
await s.join('player-a', a.gameSessionId);
await clock.advance(RANKED_QUEUE_TTL_MS - 1);
assert.equal((await s.status('player-a', a.gameSessionId)).status, 'queued');
await clock.advance(RANKED_QUEUE_TTL_MS);
assert.equal((await s.join('player-b', b.gameSessionId)).status, 'queued');
assert.equal((await s.status('player-a', a.gameSessionId)).status, 'idle');
await s.cancel('player-b', b.gameSessionId);
await f.sessions.register(a, { requestId: 'bot-request', matchId: 'bot-match', mode: 'bot', expectedPreviousSeq: 0 });
await assert.rejects(s.join('player-a', f.a2.gameSessionId), /MATCH_PENDING/);
await f.db.collection('rooms').insertOne({ members: [b.gameSessionId], open: true, expiresAt: new Date(clock.now() + 60000) });
await assert.rejects(s.join('player-b', b.gameSessionId), /MATCH_PENDING/);
} finally { await f.close(); }
});
test('another authenticated tab keeps the player queue alive after the original session expires', integration, async () => {
const f = await fixture();
try {
await f.service.join('player-a', f.a.gameSessionId);
await f.db.collection<any>('game_sessions').updateOne({ _id: f.a.gameSessionId }, { $set: { expiresAt: new Date(f.clock.now() - 1) } });
assert.equal((await f.service.status('player-a', f.a2.gameSessionId)).status, 'queued');
assert.equal((await f.service.join('player-b', f.b.gameSessionId)).status, 'confirming');
} finally { await f.close(); }
});
test('timeouts progress without polling, unlock colors only from authoritative records, and close aborts', integration, async () => {
const f = await fixture();
try {
const { service: s, a, b, clock } = f;
for (let n = 0; n < 5; n++) {
await s.join('player-a', a.gameSessionId);
const id = (await pairAndAccept(s, a.gameSessionId, b.gameSessionId)).match!.matchId;
await clock.advance(MATCH_COUNTDOWN_MS);
await s.action('player-a', a.gameSessionId, id, 1, 'guard');
await clock.advance(TURN_DURATION_MS * 3);
assert.equal((await s.status('player-b', b.gameSessionId)).match?.finished?.reason, 'timeout');
await s.leave('player-a', a.gameSessionId, id); await s.leave('player-b', b.gameSessionId, id);
}
const profile = await s.profile('player-a');
assert.equal(profile.stats.wins, 5); assert.equal(profile.stats.best, 5);
assert.ok(profile.unlockedColors.length > 1);
await s.updateProfile('player-a', { appearance: { characterId: 1, colorId: profile.unlockedColors.find(id => id !== 'white')! } });
await s.join('player-a', a.gameSessionId); await pairAndAccept(s, a.gameSessionId, b.gameSessionId);
await s.close();
const restarted = await createRankedService(f.db, clock, 'test');
try { assert.equal((await restarted.profile('player-a')).stats.wins, 5); }
finally { await restarted.close(); }
} finally { await f.close(); }
});
test('ranked HTTP authenticates identity and ignores client results for ranked awards', integration, async () => {
const f = await fixture(), app = Fastify();
try {
registerRankedApi(app, f.sessions, f.service); registerGameApi(app, f.sessions);
assert.equal((await app.inject({ method: 'GET', url: '/api/profile' })).statusCode, 401);
const headers = { authorization: `Bearer ${f.a.token}`, 'x-game-session': f.a.gameSessionId };
const original = await f.service.profile('player-a');
const locked = await app.inject({ method: 'POST', url: '/api/profile', headers, payload: {
appearance: { characterId: 0, colorId: 'white', equipment: ['headband'] }, stats: { wins: 999, best: 999 },
} });
assert.equal(locked.statusCode, 403); assert.equal(locked.json().code, 'EQUIPMENT_LOCKED');
for (const displayName of ['씨발', '<script>alert(1)</script>', '', null, { nameVersion: 1 }]) {
const rejected = await app.inject({ method: 'POST', url: '/api/profile', headers, payload: {
displayName, nameVersion: 1, appearance: { characterId: 1, colorId: 'white' },
} });
assert.equal(rejected.statusCode, 400); assert.match(rejected.json().code, /PROFILE_NAME_PROFANITY|INVALID_PROFILE_NAME/);
}
assert.deepEqual(await f.service.profile('player-a'), original);
const profile = await app.inject({ method: 'POST', url: '/api/profile', headers, payload: { playerId: 'player-b', stats: { wins: 500 } } });
assert.equal(profile.statusCode, 200); assert.equal(profile.json().data.playerId, 'player-a');
await app.inject({ method: 'POST', url: '/api/ranked/queue', headers });
const paired = await pairAndAccept(f.service, f.a.gameSessionId, f.b.gameSessionId);
const report = await app.inject({ method: 'POST', url: '/api/game-session/results', headers, payload: {
matchId: paired.match!.matchId, seq: 1, mode: 'friend', outcome: 'win', reason: 'hit',
} });
assert.notEqual(report.statusCode, 200);
assert.equal((await f.service.profile('player-a')).stats.wins, 0);
assert.equal((await app.inject({ method: 'POST', url: '/api/ranked/action', headers, payload: {} })).statusCode, 400);
assert.equal((await app.inject({ method: 'GET', url: '/api/ranked/leaderboard', headers })).statusCode, 200);
} finally { await app.close(); await f.close(); }
});
test('a slow leaderboard scan does not block match actions or deadline processing', integration, async () => {
const f = await fixture();
await f.service.close();
let release!: () => void, entered!: () => void;
const gate = new Promise<void>(resolve => { release = resolve; });
const started = new Promise<void>(resolve => { entered = resolve; });
const records = f.db.collection('ranked_matches');
const db = new Proxy(f.db, { get(target, key) {
if (key === 'collection') return (name: string) => name !== 'ranked_matches' ? target.collection(name) : new Proxy(records, {
get(collection, method) {
if (method === 'find') return (filter?: Record<string, unknown>) => {
const cursor = collection.find(filter ?? {});
if (filter === undefined) {
const iterate = cursor[Symbol.asyncIterator].bind(cursor);
cursor[Symbol.asyncIterator] = async function* () { entered(); await gate; yield* iterate(); };
}
return cursor;
};
const value = Reflect.get(collection, method);
return typeof value === 'function' ? value.bind(collection) : value;
},
});
const value = Reflect.get(target, key);
return typeof value === 'function' ? value.bind(target) : value;
} });
const s = await createRankedService(db, f.clock, 'test');
try {
await s.join('player-a', f.a.gameSessionId);
const id = (await pairAndAccept(s, f.a.gameSessionId, f.b.gameSessionId)).match!.matchId;
await f.clock.advance(MATCH_COUNTDOWN_MS);
const board = s.leaderboard('player-a'); await started;
const action = s.action('player-a', f.a.gameSessionId, id, 1, 'guard');
let timer: ReturnType<typeof setTimeout>;
const progressed = await Promise.race([action.then(() => true), new Promise<false>(resolve => { timer = setTimeout(() => resolve(false), 1000); })]);
clearTimeout(timer!); release(); await board; await action;
assert.equal(progressed, true, 'leaderboard must not own the gameplay operation lock');
} finally { release(); await s.close(); await f.close(); }
});
test('ranked polls reuse stats and concurrent leaderboard requests share a scan until a result is saved', integration, async () => {
const f = await fixture();
await f.service.close();
const records = f.db.collection('ranked_matches');
let histories = 0, boards = 0;
const db = new Proxy(f.db, { get(target, key) {
if (key === 'collection') return (name: string) => name !== 'ranked_matches' ? target.collection(name) : new Proxy(records, {
get(collection, method) {
if (method === 'find') return (filter?: Record<string, unknown>) => {
const cursor = collection.find(filter ?? {});
const iterate = cursor[Symbol.asyncIterator].bind(cursor);
cursor[Symbol.asyncIterator] = async function* () {
if (filter?.playerIds) histories++; else boards++;
yield* iterate();
};
return cursor;
};
const value = Reflect.get(collection, method);
return typeof value === 'function' ? value.bind(collection) : value;
},
});
const value = Reflect.get(target, key);
return typeof value === 'function' ? value.bind(target) : value;
} });
const s = await createRankedService(db, f.clock, 'test');
try {
await Promise.all(Array.from({ length: 8 }, () => s.profile('player-a')));
for (let n = 0; n < 8; n++) await s.status('player-a', f.a.gameSessionId);
assert.equal(histories, 1);
await Promise.all(Array.from({ length: 8 }, () => s.leaderboard('player-a')));
assert.equal(boards, 1);
await s.join('player-a', f.a.gameSessionId);
const id = (await pairAndAccept(s, f.a.gameSessionId, f.b.gameSessionId)).match!.matchId;
await s.leave('player-b', f.b.gameSessionId, id);
const profile = await s.profile('player-a');
assert.equal(profile.stats.wins, 1);
profile.stats.wins = 999;
assert.equal((await s.profile('player-a')).stats.wins, 1);
const board = await s.leaderboard('player-a');
assert.equal(board.self?.wins, 1);
board.entries[0].wins = 999;
assert.equal((await s.leaderboard('player-a')).self?.wins, 1);
assert.equal((await s.leaderboard('player-b')).self?.rank, 2);
assert.equal(boards, 2);
assert.equal(histories, 4);
f.clock.time += RANKED_LEADERBOARD_CACHE_TTL_MS;
await s.leaderboard('player-a');
assert.equal(boards, 3);
assert.equal(histories, 4);
f.clock.time += RANKED_STATS_CACHE_TTL_MS;
await s.profile('player-a');
assert.equal(histories, 5);
// Exceed the retained-player budget, then revisit the evicted player.
await Promise.all(Array.from({ length: RANKED_STATS_CACHE_LIMIT }, (_, n) => s.profile(`cache-player-${n}`)));
const before = histories;
await s.profile('player-a');
assert.equal(histories, before + 1);
} finally { await s.close(); await f.close(); }
});
test('save during stalled ranked reads returns one snapshot without retrying or caching stale data', integration, async () => {
const f = await fixture();
await f.service.close();
let release!: () => void, entered!: () => void;
const gate = new Promise<void>(resolve => { release = resolve; });
const started = new Promise<void>(resolve => { entered = resolve; });
let hold = false, held = 0, histories = 0, boards = 0;
const db = observeResultReads(f.db, async filter => {
if (filter?.playerIds) histories++; else boards++;
if (hold && (filter === undefined || filter.playerIds === 'spectator')) {
if (++held === 2) entered();
await gate;
}
});
const s = await createRankedService(db, f.clock, 'test');
try {
await s.join('player-a', f.a.gameSessionId);
const id = (await pairAndAccept(s, f.a.gameSessionId, f.b.gameSessionId)).match!.matchId;
hold = true;
const board = s.leaderboard('player-a'), spectator = s.profile('spectator');
await started;
await s.leave('player-b', f.b.gameSessionId, id);
const before = histories;
release();
assert.equal((await board).entries.length, 0, 'overlapping request returns its snapshot');
assert.equal((await spectator).stats.wins, 0);
assert.equal(boards, 1, 'a save must not restart the leaderboard scan');
assert.equal(histories, before, 'unrelated saves must not restart personal history reads');
hold = false;
await s.profile('spectator');
assert.equal(histories, before, 'unrelated player retains its cache');
assert.equal((await s.leaderboard('player-a')).self?.wins, 1);
assert.equal(boards, 2, 'overlapping stale snapshot must not be cached');
assert.equal((await s.leaderboard('player-a')).self?.wins, 1);
assert.equal(boards, 2);
} finally { release(); await s.close(); await f.close(); }
});
test('bounded leaderboard cache preserves exact self ranks outside the top entries and recovers failed scans', integration, async () => {
const f = await fixture();
await f.service.close();
const count = RANKED_LEADERBOARD_CACHE_LIMIT + 2;
const players = Array.from({ length: count }, (_, n) => `rank-${String(n).padStart(4, '0')}`);
await f.db.collection<any>('player_profiles').insertMany(players.map(_id => ({ _id, nameVersion: 1, displayName: _id })));
await f.db.collection<any>('ranked_matches').insertMany(players.map((id, order) => ({
_id: `result-${id}`, playerIds: [id, 'opponent'], winnerIndex: 0, order,
})));
let scans = 0, fail = true;
const db = observeResultReads(f.db, async filter => {
if (filter === undefined) { scans++; if (fail) throw new Error('scan failed'); }
});
const s = await createRankedService(db, f.clock, 'test');
try {
await assert.rejects(s.leaderboard(players.at(-1)!), /scan failed/);
fail = false;
const board = await s.leaderboard(players.at(-1)!);
assert.equal(board.entries.length, 100);
assert.equal(board.self?.rank, count);
assert.equal(board.self?.wins, 1);
assert.equal((await s.leaderboard(players.at(-1)!)).self?.rank, count);
assert.equal(scans, 2, 'failed promise is discarded and successful self rank is cached');
// The requested tail entry evicted rank 1 from the bounded self cache.
assert.equal((await s.leaderboard(players[0])).self?.rank, 1);
assert.equal(scans, 3);
assert.equal((await s.leaderboard(players.at(-2)!)).self?.rank, count - 1);
assert.equal(scans, 4);
} finally { await s.close(); await f.close(); }
});
test('a personal history read overlapping its own result save cannot repopulate stale stats', integration, async () => {
const f = await fixture();
await f.service.close();
let release!: () => void, entered!: () => void, hold = false;
const gate = new Promise<void>(resolve => { release = resolve; });
const started = new Promise<void>(resolve => { entered = resolve; });
const db = observeResultReads(f.db, async filter => {
if (hold && filter?.playerIds === 'player-a') { hold = false; entered(); await gate; }
});
const s = await createRankedService(db, f.clock, 'test');
try {
await s.join('player-a', f.a.gameSessionId);
const id = (await pairAndAccept(s, f.a.gameSessionId, f.b.gameSessionId)).match!.matchId;
// Expire the cached stats without running timers; leave settles the overdue draw.
f.clock.time += RANKED_STATS_CACHE_TTL_MS;
hold = true;
const old = s.profile('player-a');
await started;
await s.leave('player-b', f.b.gameSessionId, id);
release();
assert.equal((await old).stats.draws, 0);
assert.equal((await s.profile('player-a')).stats.draws, 1);
assert.equal((await s.profile('player-a')).stats.draws, 1);
} finally { release(); await s.close(); await f.close(); }
});
test('ranked timer reports only a safe failure code and retries persistence even if logging throws', integration, async () => {
const f = await fixture();
await f.service.close();
const records = f.db.collection('ranked_matches');
let fail = true, reported!: () => void;
const logged = new Promise<void>(resolve => { reported = resolve; });
const codes: string[] = [];
const db = new Proxy(f.db, { get(target, key) {
if (key === 'collection') return (name: string) => name !== 'ranked_matches' ? target.collection(name) : new Proxy(records, {
get(collection, method) {
if (method === 'updateOne') return async (...args: Parameters<typeof records.updateOne>) => {
if (fail) { fail = false; throw new Error('mongodb://secret:password@host'); }
return collection.updateOne(...args);
};
const value = Reflect.get(collection, method);
return typeof value === 'function' ? value.bind(collection) : value;
},
});
const value = Reflect.get(target, key);
return typeof value === 'function' ? value.bind(target) : value;
} });
const s = await createRankedService(db, f.clock, 'test', code => {
codes.push(code); reported(); throw new Error('logger failed');
});
try {
await s.join('player-a', f.a.gameSessionId);
await pairAndAccept(s, f.a.gameSessionId, f.b.gameSessionId);
await f.clock.advance(MATCH_COUNTDOWN_MS + TURN_DURATION_MS * 3);
await logged;
assert.deepEqual(codes, ['RANKED_BACKGROUND_FAILED']);
assert.equal(await records.countDocuments(), 0);
await f.clock.advance(1000);
assert.equal((await s.status('player-a', f.a.gameSessionId)).profile.stats.draws, 1);
assert.equal(await records.countDocuments(), 1);
} finally { await s.close(); await f.close(); }
});