Initial commit: ssessesse ki battle
This commit is contained in:
@@ -0,0 +1,46 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { randomBytes, randomUUID } from 'node:crypto';
|
||||
import { MongoClient } from 'mongodb';
|
||||
import { createGameServer } from '../src/server/create-server.js';
|
||||
import { ManualClock } from './helpers.js';
|
||||
|
||||
test('admin keeps auth, visitor metadata and current game sessions without historical matches', { skip: !process.env.TEST_MONGODB_URI }, async () => {
|
||||
const databaseName = 'dragon_ball_test_admin_' + randomUUID().replaceAll('-', ''), clock = new ManualClock();
|
||||
const app = await createGameServer({ mongoUri: process.env.TEST_MONGODB_URI!, databaseName, clientDirectory: 'dist/client', adminUser: 'operator', adminPassword: 'test-password-long', clock });
|
||||
const client = await new MongoClient(process.env.TEST_MONGODB_URI!).connect();
|
||||
const headers = { authorization: 'Basic ' + Buffer.from('operator:test-password-long').toString('base64') };
|
||||
try {
|
||||
assert.equal((await app.inject('/api/admin/analytics/report')).statusCode, 401);
|
||||
assert.equal((await app.inject('/admin/analytics')).statusCode, 401);
|
||||
assert.equal((await app.inject('/admin/analytics/index.html')).statusCode, 404);
|
||||
assert.equal((await app.inject({ url: '/api/admin/analytics/report?start=bad', headers })).statusCode, 400);
|
||||
const create = async () => (await app.inject({ method: 'POST', url: '/api/game-sessions', payload: { createRequestId: randomBytes(32).toString('base64url') } })).json().data;
|
||||
const a = await create(), b = await create();
|
||||
const post = (s: typeof a, url: string, payload: any) => app.inject({ method: 'POST', url, headers: { authorization: 'Bearer ' + s.token, 'x-game-session': s.gameSessionId }, payload });
|
||||
const data = { eventId: 'page', eventType: 'page_view', screen: 'home', metadata: { device: 'mobile', browser: 'Safari 18', referrer: 'https://example.com/a?guestToken=secret', source: 'made-up' } };
|
||||
await post(a, '/api/analytics/activity', { visible: true });
|
||||
for (const s of [a, a, b]) assert.equal((await post(s, '/api/analytics/events', { events: [data] })).statusCode, 200);
|
||||
assert.equal((await post(a, '/api/analytics/events', { events: [{ ...data, eventId: 'bad', code: 'free-text-secret' }] })).statusCode, 400);
|
||||
await post(a, '/api/analytics/events', { events: [{ eventId: 'start', eventType: 'game_started', matchId: 'local-game', mode: 'bot' }] });
|
||||
const match = (await post(a, '/api/game-session/matches', { requestId: 'register', matchId: 'local-game', mode: 'bot', expectedPreviousSeq: 0 })).json().data;
|
||||
await post(a, '/api/game-session/results', { ...match, outcome: 'win', reason: 'hit' });
|
||||
clock.time += 100;
|
||||
const report = (await app.inject({ url: '/api/admin/analytics/report', headers })).json();
|
||||
assert.equal(report.overview.visitors, 2); assert.equal(report.overview.pageViews, 2);
|
||||
assert.equal(report.clientGameplay.players, 1); assert.equal(report.clientGameplay.startedMatches, 1);
|
||||
assert.ok(!('gameplay' in report));
|
||||
assert.equal(report.live.active, 2); assert.equal(report.live.connected, null);
|
||||
assert.equal(report.live.matches, null); assert.equal(report.live.playingPeople, null);
|
||||
assert.equal(report.live.source, 'activity_reports'); assert.ok(report.live.observedAt);
|
||||
await post(b, '/api/analytics/activity', { visible: false });
|
||||
assert.equal((await app.inject({ url: '/api/admin/analytics/live', headers })).json().live.active, 1);
|
||||
const detail = (await app.inject({ url: '/api/admin/analytics/users/' + a.playerId, headers })).json();
|
||||
assert.equal(detail.gameSessions.length, 1); assert.equal(detail.gameSessions[0].stats.bot.wins, 1);
|
||||
assert.ok(!('stats' in detail)); assert.ok(!('matches' in detail)); assert.ok(!('firstMatchAt' in detail));
|
||||
const body = JSON.stringify(detail); assert.ok(!body.includes(a.token)); assert.ok(!body.includes('tokenHash')); assert.ok(!body.includes('guestTokenHash'));
|
||||
const events = JSON.stringify(await client.db(databaseName).collection('analytics_events').find().toArray());
|
||||
assert.ok(!events.includes('guestToken')); assert.ok(!events.includes('made-up'));
|
||||
assert.ok(events.includes('example.com')); assert.ok(events.includes('other'));
|
||||
} finally { await app.close(); try { await client.db(databaseName).dropDatabase(); } finally { await client.close(); } }
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { MongoClient } from 'mongodb';
|
||||
import { createAnalyticsStore } from '../src/server/analytics-store.js';
|
||||
import { kstDay, ratio, retention } from '../src/server/analytics-report.js';
|
||||
import { createStatsStore } from '../src/server/stats-store.js';
|
||||
import { RETENTION_MS, VISIT_SESSION_MS } from '../constants/analytics.js';
|
||||
|
||||
test('KST boundaries, zero denominators, calendar cohorts', () => {
|
||||
assert.equal(kstDay(new Date('2026-10-11T15:00:00Z')), '2026-10-12');
|
||||
assert.equal(kstDay(new Date('2026-10-11T14:59:59Z')), '2026-10-11');
|
||||
assert.deepEqual(ratio(0, 0), { numerator: 0, denominator: 0, percent: null });
|
||||
const visits = [{ playerId: 'a', day: '2026-10-01' }, { playerId: 'a', day: '2026-10-02' }, { playerId: 'b', day: '2026-10-02' }];
|
||||
const cohorts = retention([{ playerId: 'a', day: '2026-10-01' }, { playerId: 'b', day: '2026-10-02' }], visits, new Date('2026-10-03T00:00:00Z'));
|
||||
assert.deepEqual(cohorts[0].d1, { numerator: 1, denominator: 1, percent: 100 });
|
||||
assert.equal(cohorts[0].d7, null); assert.equal(cohorts[1].d1, null);
|
||||
assert.deepEqual(retention([{ playerId: 'a', day: '2026-09-30' }], [{ playerId: 'a', day: '2026-10-07' }], new Date('2026-10-08T00:00:00Z'))[0].d7, { numerator: 1, denominator: 1, percent: 100 });
|
||||
|
||||
});
|
||||
|
||||
test('atomic visit session rotation, duplicate collection and safe metadata on actual MongoDB', { skip: !process.env.TEST_MONGODB_URI }, async () => {
|
||||
const databaseName = `dragon_ball_test_analytics_${randomUUID().replaceAll('-', '')}`;
|
||||
const stats = await createStatsStore({ mongoUri: process.env.TEST_MONGODB_URI!, databaseName });
|
||||
try {
|
||||
const store = await createAnalyticsStore(stats.db);
|
||||
const guest = await stats.createGuest();
|
||||
const t = new Date('2026-10-01T00:00:00Z');
|
||||
const metadata = { device: 'mobile', browser: 'Chrome 141', source: 'direct_or_unknown', invitation: false };
|
||||
const first = await store.touch(guest.playerId, t, true, metadata);
|
||||
const second = await store.touch(guest.playerId, new Date(+t + 30000), true, metadata);
|
||||
assert.equal(second.sessionId, first.sessionId); assert.equal(second.activeMs, 30000);
|
||||
await store.touch(guest.playerId, new Date(+t + 40000), false, metadata);
|
||||
const visible = await store.touch(guest.playerId, new Date(+t + 60000), true, metadata);
|
||||
assert.equal(visible.activeMs, 30000);
|
||||
const [a, b] = await Promise.all([store.touch(guest.playerId, new Date(+t + 60000 + VISIT_SESSION_MS), true, metadata), store.touch(guest.playerId, new Date(+t + 60000 + VISIT_SESSION_MS), true, metadata)]);
|
||||
assert.equal(a.sessionId, b.sessionId); assert.notEqual(a.sessionId, first.sessionId);
|
||||
await store.touch(guest.playerId, new Date('2026-10-01T15:00:00Z'), true, metadata);
|
||||
const acrossDay = await store.touch(guest.playerId, new Date('2026-10-01T15:10:00Z'), true, metadata);
|
||||
assert.deepEqual(acrossDay.activeDays, ['2026-10-02']);
|
||||
assert.equal(await stats.db.collection('analytics_sessions').countDocuments(), 3);
|
||||
const event = { _id: 'event-one', playerId: guest.playerId, sessionId: first.sessionId, occurredAt: t, eventType: 'page_view', environment: 'production', schemaVersion: 1, appVersion: '0.1.0', metadata };
|
||||
await Promise.all([store.insert(event), store.insert(event)]);
|
||||
assert.equal(await stats.db.collection('analytics_events').countDocuments(), 1);
|
||||
assert.equal(+(await stats.db.collection('analytics_events').findOne())!.expiresAt, +t + RETENTION_MS);
|
||||
for (const name of ['analytics_events', 'analytics_sessions', 'analytics_presence_samples']) {
|
||||
assert.ok((await stats.db.collection(name).indexes()).some(i => i.key.expiresAt === 1 && i.expireAfterSeconds === 0));
|
||||
}
|
||||
await store.cleanup(new Date(+t + RETENTION_MS + VISIT_SESSION_MS + 86400000));
|
||||
const retained = await stats.db.collection('players').findOne({ _id: guest.playerId as any });
|
||||
assert.ok(retained); assert.equal(retained?.visit, undefined); assert.ok(retained?.guestTokenHash);
|
||||
} finally { await stats.db.dropDatabase(); await stats.close(); }
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { chooseBotAction } from '../src/shared/bot.js';
|
||||
import { isLegalAction } from '../src/shared/rules.js';
|
||||
test('bot uses only legal actions from public energy, with repeatable injected randomness', () => {
|
||||
for (const own of [0, 1, 2, 3, 10]) for (const opponent of [0, 1, 3]) for (const r of [0, .1, .5, .9, .999]) {
|
||||
assert.ok(isLegalAction(chooseBotAction(own, opponent, () => r), own));
|
||||
}
|
||||
assert.equal(chooseBotAction(0, 0, () => 0), 'charge');
|
||||
assert.notEqual(chooseBotAction(3, 3, () => 0), chooseBotAction(3, 3, () => .999));
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createClientAnalytics } from '../src/client/analytics.js';
|
||||
|
||||
test('client sends batches at 20 events or five seconds and limits activity including visibility toggles', async t => {
|
||||
t.mock.timers.enable({ apis: ['setInterval', 'Date'], now: 1000 });
|
||||
const doc = new EventTarget() as EventTarget & { hidden: boolean; referrer: string };
|
||||
doc.hidden = false; doc.referrer = '';
|
||||
Object.defineProperty(globalThis, 'document', { value: doc, configurable: true });
|
||||
Object.defineProperty(globalThis, 'location', { value: { search: '' }, configurable: true });
|
||||
const batches: any[][] = [], activities: boolean[] = [];
|
||||
const analytics = createClientAnalytics({
|
||||
events: async events => { batches.push(events); }, activity: async visible => { activities.push(visible); },
|
||||
});
|
||||
const flush = async () => { for (let i = 0; i < 5; i++) await Promise.resolve(); };
|
||||
try {
|
||||
analytics.ready();
|
||||
await flush();
|
||||
assert.equal(batches.length, 0);
|
||||
for (let i = 0; i < 19; i++) analytics.screen('match');
|
||||
await flush(); assert.equal(batches[0].length, 20);
|
||||
analytics.screen('result');
|
||||
t.mock.timers.tick(4999); await flush(); assert.equal(batches.length, 1);
|
||||
t.mock.timers.tick(1); await flush(); assert.equal(batches[1].length, 1);
|
||||
for (let i = 0; i < 10; i++) { doc.hidden = true; doc.dispatchEvent(new Event('visibilitychange')); doc.hidden = false; doc.dispatchEvent(new Event('visibilitychange')); }
|
||||
await flush(); assert.deepEqual(activities, [true, false]);
|
||||
t.mock.timers.tick(55000); await flush(); assert.deepEqual(activities, [true, false, true]);
|
||||
} finally { analytics.destroy(); Reflect.deleteProperty(globalThis, 'document'); Reflect.deleteProperty(globalThis, 'location'); }
|
||||
});
|
||||
@@ -0,0 +1,220 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { fighterArt, finisherArt } from '../src/client/fighter-art.js';
|
||||
import { appearanceKey, availableColorIds, availableEquipmentIds, DEFAULT_APPEARANCE, normalizeAppearance, type FighterAppearance } from '../src/shared/cosmetics.js';
|
||||
import { createMatchView } from '../src/client/match-view.js';
|
||||
import { emptyPlayerStats, type LocalMatchState } from '../src/shared/p2p-types.js';
|
||||
|
||||
test('invalid appearance fields default independently without accepting arbitrary style input', () => {
|
||||
for (const value of [undefined, null, false, 1, 'blue', [], {}]) {
|
||||
assert.deepEqual(normalizeAppearance(value), { characterId: 0, colorId: 'white', skinTone: 'light', hairStyle: 'black', equipment: [] });
|
||||
}
|
||||
assert.deepEqual(DEFAULT_APPEARANCE, { characterId: 0, colorId: 'white', skinTone: 'light', hairStyle: 'black', equipment: [] });
|
||||
assert.deepEqual(normalizeAppearance({ characterId: '1', colorId: 'blue' }), { ...DEFAULT_APPEARANCE, characterId: 0, colorId: 'blue' });
|
||||
assert.deepEqual(normalizeAppearance({ characterId: 1, colorId: 'red;--hair:transparent' }), { ...DEFAULT_APPEARANCE, characterId: 1, colorId: 'white', skinTone: 'tan', hairStyle: 'white' });
|
||||
assert.deepEqual(normalizeAppearance({ characterId: 2, colorId: null }), { ...DEFAULT_APPEARANCE, characterId: 0, colorId: 'white' });
|
||||
assert.deepEqual(normalizeAppearance({ characterId: 1, colorId: 'black' }), { ...DEFAULT_APPEARANCE, characterId: 1, colorId: 'black', skinTone: 'tan', hairStyle: 'white' });
|
||||
});
|
||||
|
||||
test('equipment defaults to bare parts, normalizes independently, and unlocks at approved thresholds', () => {
|
||||
const old = { characterId: 1, colorId: 'red' };
|
||||
assert.deepEqual(normalizeAppearance(old), { ...old, skinTone: 'tan', hairStyle: 'white', equipment: [] });
|
||||
assert.deepEqual(normalizeAppearance({ ...old, hairStyle: 'bald', equipment: ['headband', 'bad', 'shoes', 'shoes'] }), { ...old, skinTone: 'tan', hairStyle: 'bald', equipment: ['shoes', 'headband'] });
|
||||
assert.deepEqual(normalizeAppearance({ ...old, hairStyle: '<svg>', equipment: 'shoes' }), { ...old, skinTone: 'tan', hairStyle: 'white', equipment: [] });
|
||||
assert.deepEqual(availableEquipmentIds({ wins: 4, best: 2 }), []);
|
||||
assert.deepEqual(availableEquipmentIds({ wins: 5, best: 2 }), ['shoes']);
|
||||
assert.deepEqual(availableEquipmentIds({ wins: 0, best: 3 }), ['wristbands']);
|
||||
assert.deepEqual(availableEquipmentIds({ wins: 19, best: 3 }), ['shoes', 'wristbands']);
|
||||
assert.deepEqual(availableEquipmentIds({ wins: 20, best: 3 }), ['shoes', 'wristbands', 'headband']);
|
||||
const first = normalizeAppearance(undefined); first.equipment!.push('shoes');
|
||||
assert.deepEqual(normalizeAppearance(undefined).equipment, []);
|
||||
assert.notEqual(appearanceKey(DEFAULT_APPEARANCE), appearanceKey({ ...DEFAULT_APPEARANCE, hairStyle: 'bald' }));
|
||||
assert.notEqual(appearanceKey(DEFAULT_APPEARANCE), appearanceKey({ ...DEFAULT_APPEARANCE, equipment: ['shoes'] }));
|
||||
});
|
||||
|
||||
test('skin and hair migrate from legacy characters and remain independent of body identity', () => {
|
||||
for (const characterId of [0, 1] as const) {
|
||||
const legacy = { characterId, colorId: 'blue', equipment: ['shoes'] };
|
||||
for (const hairStyle of [undefined, 'default', 'invalid']) {
|
||||
const value = normalizeAppearance({ ...legacy, hairStyle, skinTone: 'invalid' });
|
||||
assert.equal(value.skinTone, characterId === 0 ? 'light' : 'tan');
|
||||
assert.equal(value.hairStyle, characterId === 0 ? 'black' : 'white');
|
||||
assert.deepEqual(value.equipment, ['shoes']);
|
||||
}
|
||||
for (const skinTone of ['light', 'tan'] as const) for (const hairStyle of ['black', 'white', 'bald'] as const) {
|
||||
assert.deepEqual(normalizeAppearance({ ...legacy, skinTone, hairStyle }), { ...legacy, skinTone, hairStyle });
|
||||
}
|
||||
}
|
||||
const light = normalizeAppearance({ characterId: 1, colorId: 'white', skinTone: 'light', hairStyle: 'black' });
|
||||
assert.notEqual(appearanceKey(light), appearanceKey({ ...light, skinTone: 'tan' }));
|
||||
assert.notEqual(appearanceKey(light), appearanceKey({ ...light, hairStyle: 'white' }));
|
||||
});
|
||||
|
||||
test('colors unlock at inclusive win or best-streak boundaries, with white always available', () => {
|
||||
const cases: [number, number, string[]][] = [
|
||||
[0, 0, ['white']], [4, 2, ['white']],
|
||||
[5, 0, ['white', 'orange']], [19, 0, ['white', 'orange']],
|
||||
[20, 0, ['white', 'orange', 'blue']], [49, 0, ['white', 'orange', 'blue']],
|
||||
[50, 0, ['white', 'orange', 'blue', 'red']],
|
||||
[0, 3, ['white', 'green']], [0, 4, ['white', 'green']],
|
||||
[0, 5, ['white', 'green', 'purple']], [0, 9, ['white', 'green', 'purple']],
|
||||
[0, 10, ['white', 'green', 'purple', 'black']],
|
||||
[50, 10, ['white', 'orange', 'blue', 'red', 'green', 'purple', 'black']],
|
||||
];
|
||||
for (const [wins, best, expected] of cases) assert.deepEqual(availableColorIds({ wins, best }), expected);
|
||||
});
|
||||
|
||||
test('eyewear uses permanent best-streak boundaries and a single canonical slot', () => {
|
||||
for (const [best, expected] of [[49, []], [50, ['glasses']], [99, ['glasses']], [100, ['glasses', 'sunglasses']]] as const) {
|
||||
const stats = { wins: 1000, best, current: 0 };
|
||||
assert.deepEqual(availableEquipmentIds(stats).filter(id => id === 'glasses' || id === 'sunglasses'), expected);
|
||||
}
|
||||
const inflatedCurrent = { wins: 1000, best: 49, current: 1000 };
|
||||
assert.deepEqual(availableEquipmentIds(inflatedCurrent), ['shoes', 'wristbands', 'headband']);
|
||||
const appearance = normalizeAppearance({ characterId: 1, colorId: 'black', hairStyle: 'bald',
|
||||
equipment: ['sunglasses', 'headband', 'glasses', 'shoes', 'sunglasses', 'invalid'],
|
||||
});
|
||||
assert.deepEqual(appearance.equipment, ['shoes', 'headband', 'glasses']);
|
||||
assert.deepEqual(normalizeAppearance({ equipment: ['sunglasses', 'sunglasses'] }).equipment, ['sunglasses']);
|
||||
// Normalization permits locked previews; availability is enforced only when saving.
|
||||
assert.notEqual(appearanceKey(DEFAULT_APPEARANCE), appearanceKey(normalizeAppearance({ equipment: ['glasses'] })));
|
||||
assert.notEqual(appearanceKey(normalizeAppearance({ equipment: ['glasses'] })), appearanceKey(normalizeAppearance({ equipment: ['sunglasses'] })));
|
||||
});
|
||||
|
||||
test('character geometry and hair follow the selected character independently of player slot', () => {
|
||||
for (const slot of [0, 1]) {
|
||||
for (const closeup of [false, true]) {
|
||||
const veteran = fighterArt(slot, closeup, { characterId: 1, colorId: 'white' });
|
||||
assert.match(veteran, /--hair:#d7d9cf/);
|
||||
assert.match(veteran, /m110 36-7-12 14 2/);
|
||||
assert.match(veteran, /--suit:#f0eee5;/);
|
||||
const original = fighterArt(slot, closeup, { characterId: 0, colorId: 'white' });
|
||||
assert.match(original, /--hair:#242b30/);
|
||||
assert.match(original, /m113 33-15-7 14-3/);
|
||||
assert.match(original, /--suit:#f0eee5;/);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('robe previews do not change character identity and unconfigured art keeps legacy palettes', () => {
|
||||
assert.match(fighterArt(0), /--suit:#aa5740;/);
|
||||
assert.match(fighterArt(1), /--suit:#455862;/);
|
||||
for (const characterId of [0, 1] as const) {
|
||||
const preview = fighterArt(0, false, { characterId, colorId: 'black' });
|
||||
assert.match(preview, /--suit:#34383e;/);
|
||||
assert.match(preview, characterId === 1 ? /--hair:#d7d9cf/ : /--hair:#242b30/);
|
||||
for (const pose of ['ready', 'charge', 'attack', 'guard', 'dodge', 'win', 'loss', 'draw', 'hit']) {
|
||||
assert.ok(preview.includes(`class="pose pose-${pose}"`));
|
||||
}
|
||||
for (const face of ['ready', 'focus', 'win', 'loss', 'draw', 'shock']) {
|
||||
assert.ok(preview.includes(`class="face face-${face}"`));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('blast and beam finishers use the attacker and victim appearances for either hit slot', () => {
|
||||
const appearances: [FighterAppearance, FighterAppearance] = [
|
||||
{ characterId: 1, colorId: 'white' }, { characterId: 0, colorId: 'black' },
|
||||
];
|
||||
const check = (markup: string, slot: number) => {
|
||||
assert.match(markup, slot === 0 ? /--suit:#f0eee5;/ : /--suit:#34383e;/);
|
||||
assert.match(markup, slot === 0 ? /--hair:#d7d9cf/ : /--hair:#242b30/);
|
||||
assert.match(markup, slot === 0 ? /m110 36-7-12 14 2/ : /m113 33-15-7 14-3/);
|
||||
};
|
||||
for (const hit of [0, 1] as const) {
|
||||
const attacker = 1 - hit;
|
||||
for (const attack of ['blast', 'beam'] as const) {
|
||||
const html = finisherArt(hit, attack, appearances);
|
||||
const drawings = [...html.matchAll(/<svg class="fighter-art"[\s\S]*?<\/svg>/g)].map(match => match[0]);
|
||||
const slots = attack === 'beam' ? [attacker, hit, attacker, hit, hit] : [attacker, 0, 1];
|
||||
assert.equal(drawings.length, slots.length);
|
||||
drawings.forEach((drawing, i) => check(drawing, slots[i]));
|
||||
if (attack === 'blast') {
|
||||
const fallen = html.match(/<svg class="fallen-fighter-art[\s\S]*?<\/svg>/)![0];
|
||||
assert.ok(fallen.includes(`data-fallen-index="${hit}"`));
|
||||
check(fallen, hit);
|
||||
} else {
|
||||
for (const name of ['beam-reaction-art', 'beam-dissolve-art']) {
|
||||
const style = html.match(new RegExp(`<svg class="${name}"[^>]*style="([^"]+)"`))![1];
|
||||
assert.match(style, hit === 0 ? /--suit:#f0eee5;/ : /--suit:#34383e;/);
|
||||
assert.match(style, hit === 0 ? /--hair:#d7d9cf/ : /--hair:#242b30/);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Small DOM boundary for exercising the real view's updates without a browser dependency.
|
||||
function viewHarness(t: import('node:test').TestContext) {
|
||||
class Element {
|
||||
dataset: Record<string, string> = {};
|
||||
textContent = ''; innerHTML = ''; disabled = false; hidden = false;
|
||||
children = new Map<string, Element>();
|
||||
style = { setProperty() {}, removeProperty() {} };
|
||||
classList = { toggle() {} };
|
||||
setAttribute() {}
|
||||
querySelector(selector: string): Element {
|
||||
if (!this.children.has(selector)) this.children.set(selector, new Element());
|
||||
return this.children.get(selector)!;
|
||||
}
|
||||
querySelectorAll(selector: string) { return selector === '[data-fighter]' ? actors : []; }
|
||||
}
|
||||
const actors = [new Element(), new Element()];
|
||||
const container = new Element();
|
||||
const previous = Object.getOwnPropertyDescriptor(globalThis, 'window');
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: {
|
||||
matchMedia: () => ({ matches: false, addEventListener() {}, removeEventListener() {} }),
|
||||
} });
|
||||
const view = createMatchView(container as unknown as HTMLElement, async () => true, () => {}, () => {});
|
||||
t.after(() => {
|
||||
view.destroy();
|
||||
if (previous) Object.defineProperty(globalThis, 'window', previous);
|
||||
else Reflect.deleteProperty(globalThis, 'window');
|
||||
});
|
||||
const now = Date.now();
|
||||
const state: LocalMatchState = {
|
||||
matchId: 'appearance-test', mode: 'friend', phase: 'selecting', version: 1, selfIndex: 0,
|
||||
energy: [3, 3], turnId: 1, clockNow: now, startsAt: now, deadline: now + 10000,
|
||||
ownAction: null, rematch: [false, false], opponentPresent: true, opponentLeft: false,
|
||||
saveStatus: 'saved', aborted: false,
|
||||
};
|
||||
return { view, container, actors, state };
|
||||
}
|
||||
|
||||
test('state updates replace only changed fighter appearances and retain actor state', t => {
|
||||
const { view, actors, state } = viewHarness(t);
|
||||
state.appearances = [{ characterId: 1, colorId: 'white' }, { characterId: 0, colorId: 'black' }];
|
||||
view.update(state);
|
||||
const first = actors[0].querySelector('.character-direction');
|
||||
const second = actors[1].querySelector('.character-direction');
|
||||
assert.match(first.innerHTML, /--hair:#d7d9cf/);
|
||||
assert.match(second.innerHTML, /--hair:#242b30/);
|
||||
// Mark existing SVG contents to detect an unwanted rebuild on equivalent state.
|
||||
first.innerHTML += '<!-- retained -->'; second.innerHTML += '<!-- retained -->';
|
||||
view.update({ ...state, version: 2, appearances: [{ colorId: 'white', characterId: 1 }, { colorId: 'black', characterId: 0 }] });
|
||||
assert.ok(first.innerHTML.endsWith('<!-- retained -->'));
|
||||
assert.ok(second.innerHTML.endsWith('<!-- retained -->'));
|
||||
view.update({ ...state, version: 3, appearances: [{ characterId: 1, colorId: 'red' }, state.appearances[1]] });
|
||||
assert.match(first.innerHTML, /--suit:#ad4148;/);
|
||||
assert.ok(!first.innerHTML.endsWith('<!-- retained -->'));
|
||||
assert.ok(second.innerHTML.endsWith('<!-- retained -->'));
|
||||
assert.equal(actors[0].dataset.locked, 'false');
|
||||
});
|
||||
|
||||
test('ranked results allow requeue after saving even when the opponent has left', t => {
|
||||
const { view, container, state } = viewHarness(t);
|
||||
state.ranked = true; state.phase = 'finished'; state.opponentLeft = true; state.rematch = [true, true];
|
||||
state.finished = { matchId: state.matchId, winnerIndex: 0, reason: 'timeout', stats: emptyPlayerStats(), revealEndsAt: state.clockNow, saved: false };
|
||||
view.update(state);
|
||||
assert.equal(container.querySelector('.mode-label').textContent, '랭킹 대전');
|
||||
assert.equal(container.querySelector('.rematch').textContent, '다시 매칭');
|
||||
assert.equal(container.querySelector('.rematch').disabled, true);
|
||||
state.finished.saved = true;
|
||||
view.update({ ...state, version: 2 });
|
||||
assert.equal(container.querySelector('.rematch').disabled, false);
|
||||
assert.equal(container.querySelector('.rematch-status').textContent, '');
|
||||
view.update({ ...state, version: 3, ranked: false });
|
||||
assert.equal(container.querySelector('.rematch').disabled, true);
|
||||
assert.equal(container.querySelector('.rematch').textContent, '다시 대전');
|
||||
assert.equal(container.querySelector('.rematch-status').textContent, '상대가 나갔습니다.');
|
||||
});
|
||||
@@ -0,0 +1,314 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fighterArt, finisherArt } from '../src/client/fighter-art.js';
|
||||
import { normalizeAppearance, type FighterAppearance } from '../src/shared/cosmetics.js';
|
||||
import { createMatchView } from '../src/client/match-view.js';
|
||||
import { createFinishCutscene } from '../src/client/finish-cutscene.js';
|
||||
import { emptyPlayerStats, type LocalMatchState } from '../src/shared/p2p-types.js';
|
||||
import { SKIN_TONES } from '../constants/cosmetics.js';
|
||||
|
||||
const allEquipment: FighterAppearance['equipment'] = ['shoes', 'wristbands', 'headband'];
|
||||
const section = (html: string, className: string) => {
|
||||
const start = html.indexOf(`class="${className}"`);
|
||||
assert.notEqual(start, -1, className);
|
||||
return html.slice(start, html.indexOf('</g>', start));
|
||||
};
|
||||
|
||||
test('skin palette and hair geometry are independent of each other and historical body variants', () => {
|
||||
for (const characterId of [0, 1] as const) {
|
||||
for (const skinTone of ['light', 'tan'] as const) {
|
||||
for (const hairStyle of ['black', 'white', 'bald'] as const) {
|
||||
for (const closeup of [false, true]) {
|
||||
const html = fighterArt(1 - characterId, closeup, { characterId, colorId: 'white', skinTone, hairStyle });
|
||||
const skin = SKIN_TONES.find(tone => tone.id === skinTone)!;
|
||||
assert.ok(html.includes(`--skin:${skin.skin};--skin-shade:${skin.shade};--skin-light:${skin.light};`));
|
||||
assert.ok(html.includes('--suit:#f0eee5;'), 'robe color stays independent');
|
||||
assert.equal(html.includes('m110 36-7-12 14 2'), hairStyle === 'white');
|
||||
assert.equal(html.includes('M109 29 103 35 100 44 109 39 113 32Z'), hairStyle === 'black');
|
||||
assert.equal(html.includes('class="bald-scalp"'), hairStyle === 'bald');
|
||||
if (hairStyle !== 'bald') assert.ok(html.includes(hairStyle === 'white'
|
||||
? '--hair:#d7d9cf;--hair-shadow:#7e8d90'
|
||||
: '--hair:#242b30;--hair-shadow:#454e53'));
|
||||
else assert.ok(!html.includes('fill="var(--hair)"'));
|
||||
assert.ok(html.includes(characterId === 1
|
||||
? 'm110 77-28 9-5 20 18 28 8 34 50 4'
|
||||
: 'm113 77-26 10-7 21 20 26 3 34 49 4'), 'body variant stays fixed');
|
||||
assert.equal(html.includes('m118 68 5 3m0-1 3 3'), characterId === 1, 'facial details stay fixed');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('unconfigured and legacy default hair retain each historical model appearance', () => {
|
||||
for (const characterId of [0, 1] as const) {
|
||||
const legacy = normalizeAppearance({ characterId, colorId: 'white', hairStyle: 'default' });
|
||||
assert.ok(fighterArt(characterId).includes(characterId === 1 ? '--skin:#b59173;' : '--skin:#d7ad8a;'));
|
||||
assert.ok(fighterArt(1 - characterId, true, legacy).includes(`--skin:${SKIN_TONES[characterId].skin};`));
|
||||
for (const html of [fighterArt(characterId), fighterArt(1 - characterId, true, legacy)]) {
|
||||
assert.ok(html.includes(characterId === 1 ? '--hair:#d7d9cf;' : '--hair:#242b30;'));
|
||||
assert.ok(html.includes(characterId === 1 ? 'm110 36-7-12 14 2' : 'M109 29 103 35 100 44 109 39 113 32Z'));
|
||||
}
|
||||
assert.ok(fighterArt(characterId).includes(characterId === 1 ? '--suit:#455862;' : '--suit:#aa5740;'));
|
||||
}
|
||||
});
|
||||
|
||||
test('active finishers retain independent skin and hair in portraits, fallen heads and fragment palettes', () => {
|
||||
const appearances: [FighterAppearance, FighterAppearance] = [
|
||||
{ characterId: 0, colorId: 'white', skinTone: 'tan', hairStyle: 'white', equipment: ['glasses'] },
|
||||
{ characterId: 1, colorId: 'white', skinTone: 'light', hairStyle: 'black', equipment: ['sunglasses'] },
|
||||
];
|
||||
const check = (html: string, slot: number) => {
|
||||
assert.ok(html.includes(`--skin:${SKIN_TONES[slot === 0 ? 1 : 0].skin};`));
|
||||
assert.ok(html.includes(slot === 0 ? '--hair:#d7d9cf;' : '--hair:#242b30;'));
|
||||
assert.ok(html.includes(slot === 0 ? 'm110 36-7-12 14 2' : 'M109 29 103 35 100 44 109 39 113 32Z'));
|
||||
};
|
||||
for (const hit of [0, 1] as const) {
|
||||
for (const attack of ['blast', 'beam'] as const) {
|
||||
const html = finisherArt(hit, attack, appearances);
|
||||
const drawings = [...html.matchAll(/<svg class="fighter-art"[\s\S]*?<\/svg>/g)].map(match => match[0]);
|
||||
const slots = attack === 'beam' ? [1 - hit, hit, 1 - hit, hit, hit] : [1 - hit, 0, 1];
|
||||
assert.equal(drawings.length, slots.length);
|
||||
drawings.forEach((drawing, i) => check(drawing, slots[i]));
|
||||
if (attack === 'blast') check(html.match(/<svg class="fallen-fighter-art rock-fallen-art"[\s\S]*?<\/svg>/)![0], hit);
|
||||
else for (const name of ['beam-reaction-art', 'beam-dissolve-art']) {
|
||||
const wrapper = html.match(new RegExp(`<svg class="${name}"[^>]+>`))![0];
|
||||
assert.ok(wrapper.includes(`--skin:${SKIN_TONES[hit === 0 ? 1 : 0].skin};`));
|
||||
assert.ok(wrapper.includes(hit === 0 ? '--hair:#d7d9cf;' : '--hair:#242b30;'));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('eyewear is absent from unconfigured and default appearances for both fighters', () => {
|
||||
for (const characterId of [0, 1] as const) {
|
||||
for (const closeup of [false, true]) {
|
||||
assert.ok(!fighterArt(characterId, closeup).includes('class="eyewear '));
|
||||
assert.ok(!fighterArt(characterId, closeup, { characterId, colorId: 'white' }).includes('class="eyewear '));
|
||||
}
|
||||
assert.ok(!finisherArt(characterId, 'blast').includes('class="eyewear '));
|
||||
assert.ok(!finisherArt(characterId, 'beam').includes('class="eyewear '));
|
||||
}
|
||||
});
|
||||
|
||||
test('selected clear and dark eyewear overlays every expression with either hair style and headband', () => {
|
||||
for (const characterId of [0, 1] as const) {
|
||||
for (const hairStyle of ['black', 'white', 'bald'] as const) {
|
||||
for (const headband of [false, true]) {
|
||||
for (const eyewear of ['glasses', 'sunglasses'] as const) {
|
||||
for (const closeup of [false, true]) {
|
||||
const equipment: FighterAppearance['equipment'] = headband ? ['headband', eyewear] : [eyewear];
|
||||
const html = fighterArt(1 - characterId, closeup, { characterId, colorId: 'white', hairStyle, equipment });
|
||||
const overlay = section(html, `eyewear eyewear-${eyewear}`);
|
||||
assert.equal((html.match(/class="eyewear /g) ?? []).length, 1);
|
||||
assert.ok(html.indexOf(`class="eyewear eyewear-${eyewear}"`) > html.indexOf('</g>', html.indexOf('class="face face-shock"')), 'eyewear is above all expressions');
|
||||
assert.ok(!overlay.includes('<mask') && !overlay.includes('id='), 'overlay adds no SVG IDs');
|
||||
assert.ok(overlay.includes(eyewear === 'glasses' ? 'fill-opacity=".14"' : 'fill-opacity="1"'), 'clear lenses preserve eyes; dark lenses cover eyes');
|
||||
assert.equal(html.includes('class="bald-scalp"'), hairStyle === 'bald');
|
||||
assert.equal(html.includes('fill="#864237"'), headband);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('active blast and beam portraits, fallen heads and masked remnants use each slot eyewear', () => {
|
||||
const appearances: [FighterAppearance, FighterAppearance] = [
|
||||
{ characterId: 1, colorId: 'white', equipment: ['glasses'] },
|
||||
{ characterId: 0, colorId: 'white', hairStyle: 'bald', equipment: ['headband', 'sunglasses'] },
|
||||
];
|
||||
const check = (drawing: string, slot: number) => {
|
||||
assert.ok(drawing.includes(`class="eyewear eyewear-${slot === 0 ? 'glasses' : 'sunglasses'}"`));
|
||||
assert.equal((drawing.match(/class="eyewear /g) ?? []).length, 1);
|
||||
};
|
||||
for (const hit of [0, 1] as const) {
|
||||
for (const attack of ['blast', 'beam'] as const) {
|
||||
const html = finisherArt(hit, attack, appearances);
|
||||
const drawings = [...html.matchAll(/<svg class="fighter-art"[\s\S]*?<\/svg>/g)].map(match => match[0]);
|
||||
const slots = attack === 'beam' ? [1 - hit, hit, 1 - hit, hit, hit] : [1 - hit, 0, 1];
|
||||
assert.equal(drawings.length, slots.length);
|
||||
drawings.forEach((drawing, i) => check(drawing, slots[i]));
|
||||
if (attack === 'blast') {
|
||||
check(html.match(/<svg class="fallen-fighter-art rock-fallen-art"[\s\S]*?<\/svg>/)![0], hit);
|
||||
} else {
|
||||
check(html.match(/<g class="beam-reaction-remnant"[\s\S]*?<\/svg>/)![0], hit);
|
||||
check(html.match(/<g class="fighter beam-remnant"[\s\S]*?<\/svg>/)![0], hit);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('explicit appearance starts barefoot without wristbands or headband in every pose', () => {
|
||||
for (const characterId of [0, 1] as const) {
|
||||
const html = fighterArt(1 - characterId, false, { characterId, colorId: 'white' });
|
||||
assert.ok(!html.includes('fill="#30383c"'), 'no shoe leather');
|
||||
assert.ok(!html.includes('fill="#864237"'), 'no headband or tails');
|
||||
assert.ok(html.includes('class="bare-feet"'), 'feet have dedicated anatomy');
|
||||
for (const pose of ['ready', 'charge', 'attack', 'guard', 'dodge', 'win', 'loss', 'draw', 'hit']) {
|
||||
const drawing = section(html, `pose pose-${pose}`);
|
||||
assert.ok(!drawing.includes('fill="var(--trim)"'), `${pose}: no wristband`);
|
||||
}
|
||||
assert.ok(!section(html, 'pose pose-draw').includes('m126 122h13'), 'no leftover wristband seams');
|
||||
assert.ok(html.includes('fill="#303238"'), 'belt remains');
|
||||
}
|
||||
});
|
||||
|
||||
test('bald heads have a rounded scalp and independently equipped headbands for both characters', () => {
|
||||
for (const characterId of [0, 1] as const) {
|
||||
for (const closeup of [false, true]) {
|
||||
const bare = fighterArt(1 - characterId, closeup, { characterId, colorId: 'white', hairStyle: 'bald' });
|
||||
assert.ok(bare.includes('class="bald-scalp"'));
|
||||
assert.ok(!bare.includes('fill="var(--hair)"'));
|
||||
assert.ok(!bare.includes('fill="#864237"'));
|
||||
const band = fighterArt(1 - characterId, closeup, { characterId, colorId: 'white', hairStyle: 'bald', equipment: ['headband'] });
|
||||
assert.ok(band.includes('class="bald-scalp"'));
|
||||
assert.ok(band.includes('m111 34 42-2-1 6-40 3Z'), 'headband');
|
||||
assert.ok(band.includes('m111 37-19 8-19-5'), 'tails');
|
||||
assert.ok(!band.includes('fill="var(--hair)"'));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('equipment toggles are independent and keep the equipped martial shoes', () => {
|
||||
for (const characterId of [0, 1] as const) {
|
||||
for (const equipment of [['shoes'], ['wristbands'], ['headband']] as const) {
|
||||
const html = fighterArt(0, false, { characterId, colorId: 'white', equipment: [...equipment] });
|
||||
assert.equal(html.includes('M60 275Q69 280 85 276'), equipment[0] === 'shoes');
|
||||
assert.equal(html.includes('M198 257Q203 263 213 261'), equipment[0] === 'shoes');
|
||||
assert.equal(section(html, 'pose pose-ready').includes('fill="var(--trim)"'), equipment[0] === 'wristbands');
|
||||
assert.equal(html.includes('fill="#864237"'), equipment[0] === 'headband');
|
||||
assert.ok(html.includes('fill="var(--hair)"'), 'default hair remains');
|
||||
}
|
||||
}
|
||||
assert.ok(fighterArt(0).includes('fill="#864237"'));
|
||||
assert.ok(!fighterArt(1).includes('fill="#864237"'));
|
||||
for (const slot of [0, 1]) assert.ok(fighterArt(slot).includes('M60 275Q69 280 85 276'));
|
||||
});
|
||||
|
||||
test('blast and beam carry equipment and bald scalps through both slots and active death art', () => {
|
||||
for (const hit of [0, 1] as const) {
|
||||
for (const characterId of [0, 1] as const) {
|
||||
const appearances: [FighterAppearance, FighterAppearance] = [
|
||||
{ characterId: 0, colorId: 'orange', equipment: allEquipment },
|
||||
{ characterId: 1, colorId: 'blue', equipment: allEquipment },
|
||||
];
|
||||
appearances[hit] = { characterId, colorId: 'white', hairStyle: 'bald', equipment: [] };
|
||||
for (const attack of ['blast', 'beam'] as const) {
|
||||
const html = finisherArt(hit, attack, appearances);
|
||||
const drawings = [...html.matchAll(/<svg class="fighter-art"[\s\S]*?<\/svg>/g)].map(match => match[0]);
|
||||
const victims = attack === 'beam' ? [drawings[1], drawings[3], drawings[4]] : [drawings[hit + 1]];
|
||||
for (const victim of victims) {
|
||||
assert.ok(victim.includes('class="bald-scalp"'));
|
||||
assert.ok(!victim.includes('fill="#30383c"'));
|
||||
assert.ok(!victim.includes('fill="#864237"'));
|
||||
}
|
||||
assert.ok(drawings[0].includes('fill="#30383c"'), 'attacker retains equipment');
|
||||
if (attack === 'blast') {
|
||||
const fallen = html.match(/<svg class="fallen-fighter-art rock-fallen-art"[\s\S]*?<\/svg>/)![0];
|
||||
assert.ok(fallen.includes('class="bald-scalp"'));
|
||||
assert.ok(fallen.includes('class="bare-feet"'));
|
||||
assert.ok(!fallen.includes('fill="#30383c"'));
|
||||
assert.ok(!fallen.includes('m416 259 13 4-5 15-13-4Z'));
|
||||
assert.ok(!fallen.includes('m317 230 13 10-7 7-13-10Z'));
|
||||
assert.ok(!html.includes('fallen-pose-trace'), 'archived variant is inactive');
|
||||
} else {
|
||||
const aftermath = html.slice(html.indexOf('class="cutscene-panel cutscene-fallen beam-aftermath"'));
|
||||
assert.ok(!aftermath.includes('fill="var(--hair)"'), 'bald victims have no hair fragments');
|
||||
assert.ok(!aftermath.includes('fill="#30383c"'), 'barefoot victims have no shoe fragments');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('the archived Yamcha function remains byte-for-byte unchanged', () => {
|
||||
const source = readFileSync(new URL('../src/client/fighter-art.ts', import.meta.url), 'utf8');
|
||||
const archived = source.match(/^export function yamchaFallenFighterArt[\s\S]*?^\}/m)![0];
|
||||
// SHA-256 of the exact function in the parent-provided pre-equipment backup.
|
||||
assert.equal(createHash('sha256').update(archived).digest('hex'), 'b540712bc652b3380812299c7889c64ad4b0ae18a070d5dfa9f42e157ea5be2c');
|
||||
});
|
||||
|
||||
function domHarness(t: import('node:test').TestContext) {
|
||||
class Element {
|
||||
dataset: Record<string, string> = {};
|
||||
textContent = ''; innerHTML = ''; outerHTML = ''; hidden = false;
|
||||
children = new Map<string, Element>();
|
||||
style = { setProperty() {}, removeProperty() {} };
|
||||
classList = { toggle() {} };
|
||||
setAttribute() {} addEventListener() {} showModal() {} close() {} remove() {}
|
||||
focus() { doc.activeElement = this; }
|
||||
append(node: Element) { appended.push(node); }
|
||||
querySelector(selector: string): Element {
|
||||
if (!this.children.has(selector)) this.children.set(selector, new Element());
|
||||
return this.children.get(selector)!;
|
||||
}
|
||||
querySelectorAll(selector: string) { return selector === '[data-fighter]' ? actors : []; }
|
||||
}
|
||||
const actors = [new Element(), new Element()], appended: Element[] = [];
|
||||
const container = new Element();
|
||||
const doc = { body: new Element(), activeElement: new Element(), createElement: () => new Element() };
|
||||
const globals = { document: doc, HTMLElement: Element, window: {
|
||||
matchMedia: () => ({ matches: true, addEventListener() {}, removeEventListener() {} }),
|
||||
} };
|
||||
for (const [key, value] of Object.entries(globals)) {
|
||||
const previous = Object.getOwnPropertyDescriptor(globalThis, key);
|
||||
Object.defineProperty(globalThis, key, { configurable: true, value });
|
||||
t.after(() => previous ? Object.defineProperty(globalThis, key, previous) : Reflect.deleteProperty(globalThis, key));
|
||||
}
|
||||
const now = Date.now();
|
||||
const state: LocalMatchState = {
|
||||
matchId: 'equipment-view', mode: 'friend', phase: 'selecting', version: 1, selfIndex: 0,
|
||||
energy: [3, 3], turnId: 1, clockNow: now, startsAt: now, deadline: now + 10000,
|
||||
ownAction: null, rematch: [false, false], opponentPresent: true, opponentLeft: false,
|
||||
saveStatus: 'saved', aborted: false,
|
||||
appearances: [{ characterId: 0, colorId: 'white' }, { characterId: 1, colorId: 'white' }],
|
||||
};
|
||||
return { container, actors, appended, state };
|
||||
}
|
||||
|
||||
test('match updates react to hair and equipment changes without rebuilding unchanged actors', t => {
|
||||
const { container, actors, state } = domHarness(t);
|
||||
const view = createMatchView(container as unknown as HTMLElement, async () => true, () => {}, () => {});
|
||||
try {
|
||||
view.update(state);
|
||||
const first = actors[0].querySelector('.character-direction'), second = actors[1].querySelector('.character-direction');
|
||||
second.innerHTML += '<!-- untouched -->';
|
||||
state.appearances![0] = { characterId: 0, colorId: 'white', hairStyle: 'bald', equipment: ['shoes', 'headband'] };
|
||||
view.update({ ...state, version: 2 });
|
||||
assert.ok(first.innerHTML.includes('class="bald-scalp"'));
|
||||
assert.ok(first.innerHTML.includes('M60 275Q69 280 85 276'));
|
||||
assert.ok(first.innerHTML.includes('fill="#864237"'));
|
||||
assert.ok(second.innerHTML.endsWith('<!-- untouched -->'));
|
||||
first.innerHTML += '<!-- retained -->';
|
||||
state.appearances![0].equipment = ['headband', 'shoes'];
|
||||
view.update({ ...state, version: 3 });
|
||||
assert.ok(first.innerHTML.endsWith('<!-- retained -->'), 'equipment order is not an appearance change');
|
||||
} finally { view.destroy(); }
|
||||
});
|
||||
|
||||
test('an open cutscene refreshes changed appearance art without restarting its stage or result control', t => {
|
||||
const { appended, state } = domHarness(t);
|
||||
state.matchId = 'equipment-cutscene'; state.phase = 'finished';
|
||||
state.reveal = { matchId: state.matchId, turnId: 1, actions: ['blast', 'charge'], energyBefore: [3, 3], energy: [2, 3], hitIndex: 1, winnerIndex: 0, revealedAt: state.clockNow - 10000, revealEndsAt: state.clockNow - 5000 };
|
||||
state.finished = { matchId: state.matchId, winnerIndex: 0, reason: 'hit', stats: emptyPlayerStats(), revealEndsAt: state.reveal.revealEndsAt, saved: true };
|
||||
const cutscene = createFinishCutscene(() => {});
|
||||
try {
|
||||
cutscene.update(state);
|
||||
const dialog = appended[0], button = dialog.querySelector('.cutscene-result');
|
||||
assert.equal(dialog.dataset.stage, 'complete');
|
||||
state.appearances![1] = { characterId: 1, colorId: 'white', hairStyle: 'bald', equipment: ['headband'] };
|
||||
cutscene.update({ ...state, version: 2 });
|
||||
const page = dialog.querySelector('.cutscene-page');
|
||||
assert.ok(page.outerHTML.includes('class="bald-scalp"'));
|
||||
assert.ok(page.outerHTML.includes('fill="#864237"'));
|
||||
assert.equal(dialog.dataset.stage, 'complete');
|
||||
assert.equal(dialog.querySelector('.cutscene-result'), button);
|
||||
page.outerHTML += '<!-- retained -->';
|
||||
cutscene.update({ ...state, version: 3 });
|
||||
assert.ok(page.outerHTML.endsWith('<!-- retained -->'));
|
||||
assert.equal(appended.length, 1);
|
||||
} finally { cutscene.destroy(); }
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { randomBytes, randomUUID } from 'node:crypto';
|
||||
import { MongoClient } from 'mongodb';
|
||||
import { createGameSessionStore } from '../src/server/game-session-store.js';
|
||||
import { createRoomStore } from '../src/server/rooms.js';
|
||||
import { normalizeAppearance } from '../src/shared/cosmetics.js';
|
||||
|
||||
test('bot and friend registrations snapshot all selected parts and keep them across session restoration', { skip: !process.env.TEST_MONGODB_URI }, async () => {
|
||||
const client = await new MongoClient(process.env.TEST_MONGODB_URI!, { serverSelectionTimeoutMS: 5000 }).connect();
|
||||
const db = client.db(`dragon_ball_test_parts_${randomUUID().replaceAll('-', '')}`);
|
||||
try {
|
||||
const sessions = await createGameSessionStore(db), rooms = await createRoomStore(db);
|
||||
const host = await sessions.create(randomBytes(32).toString('base64url'), 'equipment-host');
|
||||
const guest = await sessions.create(randomBytes(32).toString('base64url'), 'equipment-guest');
|
||||
const hostLook = normalizeAppearance({ characterId: 0, skinTone: 'light', colorId: 'blue', hairStyle: 'white', equipment: ['shoes', 'headband', 'glasses'] });
|
||||
const guestLook = normalizeAppearance({ characterId: 1, skinTone: 'tan', colorId: 'white', hairStyle: 'black', equipment: ['wristbands', 'sunglasses'] });
|
||||
await db.collection<any>('player_profiles').insertMany([{ _id: 'equipment-host', appearance: hostLook }, { _id: 'equipment-guest', appearance: guestLook }]);
|
||||
const bot = await sessions.register(host, { requestId: randomUUID(), matchId: randomUUID(), expectedPreviousSeq: 0, mode: 'bot' });
|
||||
assert.deepEqual(bot.appearances?.[0], hostLook);
|
||||
assert.deepEqual((await sessions.get(host)).activeMatch?.appearances?.[0], hostLook);
|
||||
await sessions.record(host, { ...bot, outcome: 'aborted', reason: 'connection_failed' });
|
||||
const room = await rooms.create(host.gameSessionId, randomUUID());
|
||||
const joined = await rooms.join(guest.gameSessionId, room.code);
|
||||
assert.deepEqual(joined.appearances, [hostLook, guestLook]);
|
||||
// Editing the lobby profile in a second tab must not change this already-paired match.
|
||||
await db.collection<any>('player_profiles').updateOne({ _id: 'equipment-host' }, { $set: { appearance: normalizeAppearance(undefined) } });
|
||||
const register = (session: typeof host, previous: number) => sessions.register(session, {
|
||||
requestId: randomUUID(), matchId: joined.currentMatchId, expectedPreviousSeq: previous, mode: 'friend', roomId: joined.id,
|
||||
});
|
||||
const [a, b] = await Promise.all([register(host, 1), register(guest, 0)]);
|
||||
assert.deepEqual(a.appearances, [hostLook, guestLook]);
|
||||
assert.deepEqual(b.appearances, a.appearances);
|
||||
assert.deepEqual((await sessions.get(guest)).activeMatch?.appearances, a.appearances);
|
||||
} finally { try { await db.dropDatabase(); } finally { await client.close(); } }
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import Fastify from 'fastify';
|
||||
import { MongoClient } from 'mongodb';
|
||||
import { createGameSessionStore } from '../src/server/game-session-store.js';
|
||||
import { registerGameApi } from '../src/server/game-api.js';
|
||||
import { createGameApi } from '../src/client/api.js';
|
||||
import { createStatsStore } from '../src/server/stats-store.js';
|
||||
|
||||
test('room creation reuses its request ID after response loss and a page reload', async () => {
|
||||
const memory = new Map<string, string>(), ids: string[] = [];
|
||||
const storage = { getItem: (k: string) => memory.get(k) ?? null, setItem: (k: string, v: string) => { memory.set(k, v); }, removeItem: (k: string) => { memory.delete(k); } };
|
||||
const send: typeof fetch = async (input, init) => {
|
||||
const data = String(input) === '/api/rooms' ? (() => { ids.push(JSON.parse(String(init?.body)).requestId); if (ids.length === 1) throw new Error('response lost'); return { id: 'room', status: 'waiting' }; })() : { gameSessionId: 'session', token: 'token' };
|
||||
return new Response(JSON.stringify({ ok: true, data }));
|
||||
};
|
||||
const first = createGameApi(storage, send); await first.ensureSession(); await assert.rejects(first.createRoom(), /response lost/);
|
||||
const restored = createGameApi(storage, send); await restored.ensureSession(); await restored.createRoom(); await restored.createRoom();
|
||||
assert.equal(ids[0], ids[1]); assert.notEqual(ids[1], ids[2]);
|
||||
});
|
||||
|
||||
test('failed room cleanup survives refresh and expired or deleted rooms count as departed', async () => {
|
||||
const memory = new Map<string, string>(); let status = 503, leaves = 0;
|
||||
const storage = { getItem: (k: string) => memory.get(k) ?? null, setItem: (k: string, v: string) => { memory.set(k, v); }, removeItem: (k: string) => { memory.delete(k); } };
|
||||
const send: typeof fetch = async input => {
|
||||
if (String(input).endsWith('/leave')) { leaves++; return new Response(JSON.stringify({ ok: false, code: 'ROOM_EXPIRED', message: 'gone' }), { status }); }
|
||||
return new Response(JSON.stringify({ ok: true, data: { gameSessionId: 's', token: 't'.repeat(43) } }));
|
||||
};
|
||||
const first = createGameApi(storage, send); await first.ensureSession(); await assert.rejects(first.leaveRoom('r'));
|
||||
const second = createGameApi(storage, send); await second.ensureSession(); status = 410;
|
||||
assert.equal(await second.resumeRoomLeave(), true); assert.equal(await second.resumeRoomLeave(), false);
|
||||
status = 404; await second.leaveRoom('deleted'); assert.equal(leaves, 3);
|
||||
});
|
||||
|
||||
test('HTTP client restores its session, records once and surfaces conflicts', { skip: !process.env.TEST_MONGODB_URI }, async () => {
|
||||
const client = await new MongoClient(process.env.TEST_MONGODB_URI!, { serverSelectionTimeoutMS: 5000 }).connect();
|
||||
const db = client.db(`dragon_ball_test_api_${randomUUID().replaceAll('-', '')}`);
|
||||
const app = Fastify();
|
||||
try {
|
||||
registerGameApi(app, await createGameSessionStore(db));
|
||||
const memory = new Map<string, string>();
|
||||
const storage = { getItem: (k: string) => memory.get(k) ?? null, setItem: (k: string, v: string) => { memory.set(k, v); }, removeItem: (k: string) => { memory.delete(k); } };
|
||||
const localFetch: typeof fetch = async (input, init) => {
|
||||
const headers: Record<string, string> = {}; new Headers(init?.headers).forEach((value, key) => { headers[key] = value; });
|
||||
const response = await app.inject({ method: (init?.method ?? 'GET') as 'GET' | 'POST', url: String(input), headers, payload: init?.body as string | undefined });
|
||||
return new Response(response.body, { status: response.statusCode, headers: { 'content-type': 'application/json' } });
|
||||
};
|
||||
const api = createGameApi(storage, localFetch);
|
||||
const session = await api.ensureSession();
|
||||
const restored = createGameApi(storage, localFetch); await restored.ensureSession();
|
||||
assert.equal(restored.session.gameSessionId, session.gameSessionId);
|
||||
const match = await api.registerMatch({ requestId: randomUUID(), matchId: randomUUID(), mode: 'bot', expectedPreviousSeq: 0 });
|
||||
const win = { ...match, outcome: 'win' as const, reason: 'hit' as const };
|
||||
await restored.sync();
|
||||
assert.equal(restored.session.activeMatch?.matchId, match.matchId);
|
||||
assert.equal((await api.record(win)).stats.bot.wins, 1);
|
||||
assert.equal((await restored.sync()).activeMatch, undefined);
|
||||
assert.equal((await restored.record(win)).status, 'duplicate');
|
||||
await assert.rejects(api.record({ ...win, outcome: 'loss' }), (e: any) => e.status === 409 && e.code === 'RESULT_CONFLICT');
|
||||
assert.equal((await app.inject({ method: 'GET', url: '/api/game-session' })).statusCode, 401);
|
||||
assert.equal((await api.sync()).lastAppliedSeq, 1);
|
||||
} finally { await app.close(); try { await db.dropDatabase(); } finally { await client.close(); } }
|
||||
});
|
||||
|
||||
test('session creation retry preserves linked visitor credentials and rejects invalid keys before creating visitors', { skip: !process.env.TEST_MONGODB_URI }, async () => {
|
||||
const visitors = await createStatsStore({ mongoUri: process.env.TEST_MONGODB_URI!, databaseName: `dragon_ball_test_api_${randomUUID().replaceAll('-', '')}` });
|
||||
const app = Fastify();
|
||||
try {
|
||||
registerGameApi(app, await createGameSessionStore(visitors.db), visitors);
|
||||
const createRequestId = 'a'.repeat(43);
|
||||
const first = (await app.inject({ method: 'POST', url: '/api/game-sessions', payload: { createRequestId } })).json().data;
|
||||
const retry = (await app.inject({ method: 'POST', url: '/api/game-sessions', payload: { createRequestId } })).json().data;
|
||||
assert.equal(retry.gameSessionId, first.gameSessionId);
|
||||
assert.equal(retry.guestToken, first.guestToken);
|
||||
assert.equal((await visitors.findGuest(retry.guestToken))?.playerId, retry.playerId);
|
||||
assert.equal((await app.inject({ method: 'POST', url: '/api/game-sessions', payload: { createRequestId: 'bad' } })).statusCode, 400);
|
||||
assert.equal(await visitors.db.collection('players').countDocuments(), 1);
|
||||
} finally { await app.close(); try { await visitors.db.dropDatabase(); } finally { await visitors.close(); } }
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { randomBytes, randomUUID } from 'node:crypto';
|
||||
import { MongoClient } from 'mongodb';
|
||||
import { createGameSessionStore } from '../src/server/game-session-store.js';
|
||||
|
||||
const uri = process.env.TEST_MONGODB_URI;
|
||||
test('Standalone session counters apply concurrent and late results exactly once', { skip: !uri }, async () => {
|
||||
const client = await new MongoClient(uri!, { serverSelectionTimeoutMS: 5000 }).connect();
|
||||
const db = client.db(`dragon_ball_test_p2p_records_${randomUUID().replaceAll('-', '')}`);
|
||||
try {
|
||||
let now = Date.now();
|
||||
const store = await createGameSessionStore(db, () => now);
|
||||
const creationKey = randomBytes(32).toString('base64url');
|
||||
const session = await store.create(creationKey);
|
||||
assert.deepEqual(await store.create(creationKey), session);
|
||||
const auth = { gameSessionId: session.gameSessionId, token: session.token };
|
||||
const registration = { requestId: randomUUID(), matchId: randomUUID(), mode: 'bot' as const, expectedPreviousSeq: 0 };
|
||||
const active = await store.register(auth, registration);
|
||||
assert.deepEqual(await store.register(auth, registration), active);
|
||||
const win = { ...active, outcome: 'win' as const, reason: 'hit' as const };
|
||||
const replies = await Promise.all(Array.from({ length: 20 }, () => store.record(auth, win)));
|
||||
assert.equal(replies.filter(r => r.status === 'applied').length, 1);
|
||||
assert.equal((await store.get(auth)).stats.bot.wins, 1);
|
||||
assert.equal((await store.get(auth)).stats.bot.current, 1);
|
||||
assert.equal((await store.record(auth, win)).status, 'duplicate'); // lost response retry
|
||||
await assert.rejects(store.record(auth, { ...win, outcome: 'loss' }), /RESULT_CONFLICT/);
|
||||
const second = await store.register(auth, { ...registration, requestId: randomUUID(), matchId: randomUUID(), expectedPreviousSeq: 1 });
|
||||
await store.record(auth, { ...second, outcome: 'loss', reason: 'hit' });
|
||||
assert.equal((await store.record(auth, win)).status, 'stale');
|
||||
assert.deepEqual((await store.get(auth)).stats.bot, { wins: 1, losses: 1, draws: 0, current: 0, best: 1 });
|
||||
const registrations = await Promise.allSettled([0, 1].map(() => store.register(auth, { ...registration, requestId: randomUUID(), matchId: randomUUID(), expectedPreviousSeq: 2 })));
|
||||
assert.equal(registrations.filter(r => r.status === 'fulfilled').length, 1);
|
||||
const third = (await store.get(auth)).activeMatch!;
|
||||
await store.record(auth, { ...third, outcome: 'aborted', reason: 'connection_failed' });
|
||||
assert.deepEqual((await store.get(auth)).stats.bot, { wins: 1, losses: 1, draws: 0, current: 0, best: 1 });
|
||||
await assert.rejects(store.record(auth, { ...third, seq: 9, matchId: randomUUID(), outcome: 'win', reason: 'hit' }), /NO_ACTIVE_MATCH/);
|
||||
await assert.rejects(store.get({ ...auth, token: randomBytes(32).toString('base64url') }), /INVALID_SESSION/);
|
||||
const fourth = await store.register(auth, { ...registration, requestId: randomUUID(), matchId: randomUUID(), expectedPreviousSeq: 3 });
|
||||
await store.record(auth, { ...fourth, outcome: 'draw', reason: 'double_timeout' });
|
||||
assert.equal((await store.get(auth)).stats.bot.draws, 1);
|
||||
now += 91 * 86400000;
|
||||
await assert.rejects(store.get(auth), /INVALID_SESSION/);
|
||||
} finally { try { await db.dropDatabase(); } finally { await client.close(); } }
|
||||
});
|
||||
test('friend registration requires membership, but expired room cannot prevent recording', { skip: !uri }, async () => {
|
||||
const client = await new MongoClient(uri!, { serverSelectionTimeoutMS: 5000 }).connect();
|
||||
const db = client.db(`dragon_ball_test_p2p_member_${randomUUID().replaceAll('-', '')}`);
|
||||
try {
|
||||
let now = Date.now(); const store = await createGameSessionStore(db, () => now);
|
||||
const session = await store.create(randomBytes(32).toString('base64url'));
|
||||
const other = await store.create(randomBytes(32).toString('base64url'));
|
||||
const request = { requestId: randomUUID(), matchId: randomUUID(), roomId: randomUUID(), mode: 'friend' as const, expectedPreviousSeq: 0 };
|
||||
await assert.rejects(store.register(session, request), /ROOM_NOT_FOUND/);
|
||||
await db.collection<any>('rooms').insertOne({ _id: request.roomId, hostGameSessionId: session.gameSessionId, guestGameSessionId: other.gameSessionId, status: 'paired', currentMatchId: request.matchId, expiresAt: new Date(now + 1000) });
|
||||
const active = await store.register(session, request); now += 2000;
|
||||
await store.record(session, { ...active, outcome: 'win', reason: 'hit' });
|
||||
assert.equal((await store.get(session)).stats.friend.current, 1);
|
||||
assert.equal((await store.get(session)).stats.bot.wins, 0);
|
||||
} finally { try { await db.dropDatabase(); } finally { await client.close(); } }
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { Clock } from '../src/server/clock.js';
|
||||
export class ManualClock implements Clock {
|
||||
time = Date.now(); id = 0;
|
||||
timers = new Map<number, { at: number; callback: () => void }>();
|
||||
now = () => this.time;
|
||||
setTimeout = (callback: () => void, delay: number) => { const id = ++this.id; this.timers.set(id, { at: this.time + delay, callback }); return id; };
|
||||
clearTimeout = (id: any) => { this.timers.delete(id); };
|
||||
async advance(ms: number) {
|
||||
const target = this.time + ms;
|
||||
for (;;) {
|
||||
const next = [...this.timers.entries()].filter(([, t]) => t.at <= target).sort((a, b) => a[1].at - b[1].at)[0];
|
||||
if (!next) break;
|
||||
this.time = next[1].at; this.timers.delete(next[0]); next[1].callback();
|
||||
await new Promise(r => setTimeout(r, 20));
|
||||
}
|
||||
this.time = target; await new Promise(r => setTimeout(r, 20));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import Fastify from 'fastify';
|
||||
import { createStatsStore } from '../src/server/stats-store.js';
|
||||
import { createGameSessionStore } from '../src/server/game-session-store.js';
|
||||
import { createAnalyticsStore } from '../src/server/analytics-store.js';
|
||||
import { Analytics } from '../src/server/analytics.js';
|
||||
import { registerGameApi, registerAnalyticsApi } from '../src/server/game-api.js';
|
||||
import { ManualClock } from './helpers.js';
|
||||
import { getAnalyticsReport } from '../src/server/analytics-report.js';
|
||||
|
||||
test('HTTP analytics batches deduplicate, throttle activity writes and keep client game summaries separate', { skip: !process.env.TEST_MONGODB_URI }, async () => {
|
||||
const stats = await createStatsStore({ mongoUri: process.env.TEST_MONGODB_URI!, databaseName: `dragon_ball_test_http_${randomUUID().replaceAll('-', '')}` });
|
||||
const app = Fastify(), clock = new ManualClock();
|
||||
const store = await createAnalyticsStore(stats.db);
|
||||
let touches = 0; const originalTouch = store.touch;
|
||||
store.touch = async (...args) => { touches++; return originalTouch(...args); };
|
||||
const analytics = new Analytics(store, clock, 'test');
|
||||
try {
|
||||
const sessions = await createGameSessionStore(stats.db);
|
||||
registerGameApi(app, sessions, stats); registerAnalyticsApi(app, sessions, analytics);
|
||||
const session = (await app.inject({ method: 'POST', url: '/api/game-sessions', payload: { createRequestId: 'b'.repeat(43) } })).json().data;
|
||||
const headers = { authorization: `Bearer ${session.token}`, 'x-game-session': session.gameSessionId };
|
||||
const post = (url: string, payload: any) => app.inject({ method: 'POST', url, headers, payload });
|
||||
const events = [{ eventId: 'page', eventType: 'page_view', screen: 'home', metadata: { device: 'mobile' } }, ...Array.from({ length: 19 }, (_, i) => ({ eventId: `screen${i}`, eventType: 'screen_view', screen: 'match' }))];
|
||||
assert.equal((await post('/api/analytics/events', { events })).statusCode, 200);
|
||||
assert.equal((await post('/api/analytics/events', { events })).statusCode, 200);
|
||||
assert.equal(await stats.db.collection('analytics_events').countDocuments(), 20);
|
||||
assert.equal(touches, 1);
|
||||
for (let i = 0; i < 10; i++) await post('/api/analytics/activity', { visible: true });
|
||||
assert.equal(touches, 1);
|
||||
assert.equal(analytics.live().active, 1);
|
||||
clock.time += 60000;
|
||||
await post('/api/analytics/activity', { visible: true }); assert.equal(touches, 2);
|
||||
for (let i = 0; i < 10; i++) await post('/api/analytics/activity', { visible: false });
|
||||
assert.equal(touches, 3); assert.equal(analytics.live().active, 0);
|
||||
const summary = await post('/api/analytics/events', { events: [{ eventId: 'start', eventType: 'game_started', matchId: 'm', mode: 'friend' }] });
|
||||
assert.equal(summary.statusCode, 200);
|
||||
const event = await stats.db.collection('analytics_events').findOne({ eventType: 'game_started' });
|
||||
assert.equal(event?.source, 'client'); assert.equal(event?.gameSessionId, session.gameSessionId);
|
||||
assert.equal(await stats.db.collection('matches').countDocuments(), 0);
|
||||
assert.equal((await post('/api/analytics/events', { events: Array(21).fill(events[0]) })).statusCode, 400);
|
||||
assert.equal((await post('/api/analytics/events', { events: [{ eventId: 'fake', eventType: 'match_started' }] })).statusCode, 400);
|
||||
assert.equal((await app.inject({ method: 'POST', url: '/api/analytics/activity', payload: { visible: true } })).statusCode, 401);
|
||||
} finally { await app.close(); await analytics.close(); try { await stats.db.dropDatabase(); } finally { await stats.close(); } }
|
||||
});
|
||||
|
||||
test('current client summaries remain available without legacy server metrics', { skip: !process.env.TEST_MONGODB_URI }, async () => {
|
||||
const stats = await createStatsStore({ mongoUri: process.env.TEST_MONGODB_URI!, databaseName: `dragon_ball_test_source_${randomUUID().replaceAll('-', '')}` });
|
||||
const clock = new ManualClock(), store = await createAnalyticsStore(stats.db);
|
||||
const analytics = new Analytics(store, clock);
|
||||
try {
|
||||
const guest = await stats.createGuest(), p = { playerId: guest.playerId, gameSessionId: 'game-session' };
|
||||
await analytics.clientEvent(p, { eventId: 'page', eventType: 'page_view', screen: 'home' });
|
||||
await analytics.clientEvent(p, { eventId: 'start', eventType: 'game_started', matchId: 'match', mode: 'friend' });
|
||||
await analytics.clientEvent(p, { eventId: 'finish', eventType: 'game_finished', matchId: 'match', mode: 'friend', outcome: 'win', reason: 'hit' });
|
||||
await analytics.clientEvent(p, { eventId: 'finish-after-refresh', eventType: 'game_finished', matchId: 'match', mode: 'friend', outcome: 'win', reason: 'hit' });
|
||||
clock.time++;
|
||||
const report = await getAnalyticsReport(stats.db, { start: new Date(clock.now() - 86400000), end: new Date(clock.now()) }, new Date(clock.now()));
|
||||
assert.equal(report.clientGameplay.source, 'client');
|
||||
assert.equal(report.clientGameplay.players, 1); assert.equal(report.clientGameplay.startedMatches, 1);
|
||||
assert.equal(report.clientGameplay.resultReports, 1); assert.equal(report.clientGameplay.actions, null);
|
||||
assert.equal(report.clientGameplay.turns, null); assert.equal(report.clientGameplay.serverVerifiedCompletion, null);
|
||||
assert.ok(!('gameplay' in report));
|
||||
assert.ok(!('sessionConversion' in report.overview));
|
||||
assert.ok(!('visitOnly' in report.overview));
|
||||
assert.ok(!('conversion' in report.acquisition.source[0]));
|
||||
} finally { await analytics.close(); try { await stats.db.dropDatabase(); } finally { await stats.close(); } }
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { mkdtemp, readFile, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, resolve, sep } from 'node:path';
|
||||
import { BSON, MongoClient } from 'mongodb';
|
||||
import { cleanupLegacyData } from '../scripts/cleanup-legacy-data.js';
|
||||
|
||||
test('legacy cleanup backs up removed data, preserves current data and is repeatable', { skip: !process.env.TEST_MONGODB_URI }, async () => {
|
||||
const client = await new MongoClient(process.env.TEST_MONGODB_URI!).connect();
|
||||
const db = client.db(`dragon_ball_test_cleanup_${randomUUID().replaceAll('-', '')}`);
|
||||
const directory = await mkdtemp(join(tmpdir(), 'dragon-ball-cleanup-'));
|
||||
try {
|
||||
const date = new Date();
|
||||
await db.collection('matches').insertOne({ endedAt: date });
|
||||
await db.collection('players').insertOne({ firstMatchAt: date, firstVisitAt: date, guestTokenHash: 'keep-identity' });
|
||||
await db.collection('analytics_events').insertMany([{ eventType: 'match_started' }, { eventType: 'page_view' }, { eventType: 'game_finished', source: 'client' }]);
|
||||
await db.collection('analytics_presence_samples').insertMany([{ connected: 2 }, { source: 'activity_reports', active: 1 }]);
|
||||
for (const name of ['game_sessions', 'rooms', 'analytics_sessions', 'analytics_metadata']) await db.collection(name).insertOne({ keep: true });
|
||||
const before = await cleanupLegacyData(db);
|
||||
assert.equal(before.applied, false); assert.deepEqual(before.counts, { matches: 1, events: 1, samples: 1, playerFields: 1 });
|
||||
assert.equal(await db.collection('matches').countDocuments(), 1);
|
||||
const path = join(directory, 'backup.ejson');
|
||||
await cleanupLegacyData(db, path);
|
||||
const backup = BSON.EJSON.parse(await readFile(path, 'utf8'));
|
||||
assert.equal(+backup.matches[0].endedAt, +date); assert.equal(backup.players.length, 1);
|
||||
assert.equal((await db.listCollections({ name: 'matches' }).toArray()).length, 0);
|
||||
assert.equal(await db.collection('analytics_events').countDocuments(), 2);
|
||||
assert.equal(await db.collection('analytics_presence_samples').countDocuments(), 1);
|
||||
const player = await db.collection('players').findOne();
|
||||
assert.equal(player?.firstMatchAt, undefined); assert.equal(player?.guestTokenHash, 'keep-identity'); assert.equal(+player!.firstVisitAt, +date);
|
||||
for (const name of ['game_sessions', 'rooms', 'analytics_sessions', 'analytics_metadata']) assert.equal(await db.collection(name).countDocuments({ keep: true }), 1);
|
||||
const again = await cleanupLegacyData(db, join(directory, 'second.ejson'));
|
||||
assert.deepEqual(again.counts, { matches: 0, events: 0, samples: 0, playerFields: 0 });
|
||||
await assert.rejects(cleanupLegacyData(db, path), { code: 'EEXIST' });
|
||||
} finally {
|
||||
await db.dropDatabase(); await client.close();
|
||||
assert.ok(resolve(directory).startsWith(resolve(tmpdir()) + sep + 'dragon-ball-cleanup-'));
|
||||
await rm(directory, { recursive: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import type { GameApi } from '../src/client/api.js';
|
||||
import { createLobbyFeatures } from '../src/client/lobby-features.js';
|
||||
import { normalizeAppearance, type FighterAppearance } from '../src/shared/cosmetics.js';
|
||||
import type { PlayerProfile } from '../src/shared/ranked-types.js';
|
||||
|
||||
// A selector/event surface, not an HTML parser or rendering implementation.
|
||||
// The test invokes the production selection/equip handlers and observes the
|
||||
// appearance callback used by main.ts to display the equipped character.
|
||||
function documentSurface() {
|
||||
const ids = new Map<string, Element>();
|
||||
class Element {
|
||||
nodes = new Map<string, Element>();
|
||||
listeners = new Map<string, (event: any) => void>();
|
||||
classList = { remove() {} };
|
||||
disabled = false; textContent = ''; innerHTML = ''; value = '';
|
||||
onclick?: () => void | Promise<void>;
|
||||
set id(id: string) { ids.set(id, this); }
|
||||
querySelector(selector: string): Element {
|
||||
if (!this.nodes.has(selector)) this.nodes.set(selector, new Element());
|
||||
return this.nodes.get(selector)!;
|
||||
}
|
||||
addEventListener(name: string, listener: (event: any) => void) { this.listeners.set(name, listener); }
|
||||
prepend() {} after() {} append() {} showModal() {} close() {}
|
||||
}
|
||||
const document = {
|
||||
body: new Element(), createElement: () => new Element(), querySelector: () => new Element(),
|
||||
getElementById(id: string) {
|
||||
if (!ids.has(id)) ids.set(id, new Element());
|
||||
return ids.get(id)!;
|
||||
},
|
||||
};
|
||||
return document;
|
||||
}
|
||||
|
||||
test('a profile read started before equip cannot undo the saved appearance', 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 = (skinTone: 'light' | 'tan'): PlayerProfile => ({
|
||||
playerId: 'player-a', displayName: 'Alpha', 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;
|
||||
const oldRead = new Promise<PlayerProfile>(resolve => { finishOldRead = resolve; });
|
||||
let reads = 0, savedAppearance: FighterAppearance | undefined;
|
||||
const appearances: FighterAppearance[] = [];
|
||||
let savedDisplayed!: () => void;
|
||||
const displayed = new Promise<void>(resolve => { savedDisplayed = resolve; });
|
||||
const api = { async request(path: string, method?: string, body?: { appearance: FighterAppearance }) {
|
||||
assert.equal(path, '/api/profile');
|
||||
if (method === 'POST') {
|
||||
savedAppearance = body!.appearance;
|
||||
return { ...profile('light'), appearance: normalizeAppearance(savedAppearance) };
|
||||
}
|
||||
return ++reads === 1 ? profile('light') : oldRead;
|
||||
} } as unknown as GameApi;
|
||||
const lobby = createLobbyFeatures({ api, onQueue() {}, onMatch() {}, onHome() {}, onAvailabilityChange() {},
|
||||
onError(error) { throw error; },
|
||||
onAppearance(appearance) {
|
||||
appearances.push(appearance);
|
||||
if (appearance.skinTone === 'tan') savedDisplayed();
|
||||
},
|
||||
});
|
||||
await lobby.initialize(false);
|
||||
const opening = document.getElementById('appearance-open').onclick!();
|
||||
const dialog = document.getElementById('appearance-dialog');
|
||||
dialog.querySelector('.skin-options').listeners.get('click')!({
|
||||
target: { closest: () => ({ dataset: { skin: 'tan' } }) },
|
||||
});
|
||||
const equip = dialog.querySelector('.equip');
|
||||
assert.equal(equip.disabled, false);
|
||||
equip.onclick!();
|
||||
await displayed;
|
||||
assert.deepEqual(savedAppearance, normalizeAppearance({ characterId: 0, skinTone: 'tan', colorId: 'white' }));
|
||||
finishOldRead(profile('light'));
|
||||
await opening;
|
||||
assert.deepEqual(appearances.at(-1), savedAppearance, 'the late GET must not revert the displayed equipment');
|
||||
assert.equal(equip.disabled, true, 'the saved preview must still be equipped');
|
||||
});
|
||||
|
||||
for (const editedDuringLoad of [false, true]) {
|
||||
test(editedDuringLoad
|
||||
? 'refresh preserves hair edits made while the profile is loading'
|
||||
: 'refresh replaces an untouched preview with equipment saved in another tab', 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 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,
|
||||
stats: { wins: 5, losses: 0, draws: 0, current: 5, best: 5 },
|
||||
unlockedColors: ['white'], unlockedEquipment: ['shoes', 'wristbands'],
|
||||
});
|
||||
let finishRead!: (profile: PlayerProfile) => void;
|
||||
const pendingRead = new Promise<PlayerProfile>(resolve => { finishRead = resolve; });
|
||||
let reads = 0, savedAppearance: FighterAppearance | undefined;
|
||||
let savedDisplayed!: () => void;
|
||||
const displayed = new Promise<void>(resolve => { savedDisplayed = resolve; });
|
||||
const api = { async request(path: string, method?: string, body?: { appearance: FighterAppearance }) {
|
||||
assert.equal(path, '/api/profile');
|
||||
if (method === 'POST') { savedAppearance = body!.appearance; return profile(savedAppearance); }
|
||||
return ++reads === 1 ? profile(cached) : pendingRead;
|
||||
} } as unknown as GameApi;
|
||||
const lobby = createLobbyFeatures({ api, onQueue() {}, onMatch() {}, onHome() {}, onAvailabilityChange() {},
|
||||
onError(error) { throw error; }, onAppearance() { if (savedAppearance) savedDisplayed(); },
|
||||
});
|
||||
await lobby.initialize(false);
|
||||
const opening = document.getElementById('appearance-open').onclick!();
|
||||
const dialog = document.getElementById('appearance-dialog');
|
||||
const chooseBald = () => dialog.querySelector('.hair-options').listeners.get('click')!({
|
||||
target: { closest: () => ({ dataset: { hair: 'bald' } }) },
|
||||
});
|
||||
if (editedDuringLoad) chooseBald();
|
||||
finishRead(profile(remote));
|
||||
await opening;
|
||||
const equip = dialog.querySelector('.equip');
|
||||
assert.equal(equip.disabled, !editedDuringLoad, 'an untouched refreshed preview is already equipped');
|
||||
if (!editedDuringLoad) chooseBald();
|
||||
assert.equal(equip.disabled, false);
|
||||
equip.onclick!();
|
||||
await displayed;
|
||||
assert.deepEqual(savedAppearance, editedDuringLoad
|
||||
? normalizeAppearance({ characterId: 0, colorId: 'white', hairStyle: 'bald', equipment: [] })
|
||||
: normalizeAppearance({ characterId: 1, colorId: 'white', hairStyle: 'bald', equipment: ['shoes'] }));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import vm from 'node:vm';
|
||||
import ts from 'typescript';
|
||||
import { GAME_CHECKPOINT_KEY, GAME_ROOM_KEY } from '../constants/recording.js';
|
||||
import { HOME_TELEPORT_APPEARANCE } from '../constants/home.js';
|
||||
import { createGameApi, ApiClientError } from '../src/client/api.js';
|
||||
import { emptyPlayerStats } from '../src/shared/p2p-types.js';
|
||||
|
||||
const source = readFileSync('src/client/main.ts', 'utf8').replace(/^import .*;\r?\n/gm, '');
|
||||
const compiled = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.None } }).outputText;
|
||||
const flush = async () => { for (let i = 0; i < 8; i++) await new Promise(r => setImmediate(r)); };
|
||||
async function setup(kind: 'expired' | 'orphan' | 'rematch-wait') {
|
||||
const mem = new Map<string, string>(), calls: string[] = [], polls = new Map<number, () => void>(), launched: string[] = [];
|
||||
const active = { matchId: 'm', seq: 1, mode: 'friend', roomId: 'r' };
|
||||
const session: any = { gameSessionId: 's', token: 't'.repeat(43), stats: emptyPlayerStats(), lastAppliedSeq: kind === 'orphan' ? 0 : 1, ...(kind === 'orphan' ? { activeMatch: active } : {}) };
|
||||
const room: any = { id: 'r', code: 'ABCDEF', currentMatchId: 'm', round: 1, selfIndex: 0, status: 'paired', count: 2, expiresAt: Date.now() + 60000, rematch: [kind === 'rematch-wait', false] };
|
||||
let open = true, pollId = 0;
|
||||
mem.set(GAME_ROOM_KEY, JSON.stringify({ gameSessionId: 's', room }));
|
||||
if (kind !== 'orphan') mem.set(GAME_CHECKPOINT_KEY, JSON.stringify({ gameSessionId: 's', activeMatch: active, checkpoint: { state: {
|
||||
matchId: 'm', mode: 'friend', phase: 'finished', selfIndex: 0, version: 1, finished: { saved: true, stats: session.stats, reason: 'hit' }, rematch: room.rematch, saveStatus: 'saved',
|
||||
} } }));
|
||||
const send: typeof fetch = async (input, init) => {
|
||||
const path = String(input); calls.push(path); let data: any = session;
|
||||
if (path === '/api/rooms/r' || path.endsWith('/leave')) {
|
||||
if (kind === 'expired') return new Response(JSON.stringify({ ok: false, code: 'ROOM_EXPIRED', message: 'expired' }), { status: 410 });
|
||||
if (path.endsWith('/leave')) open = false;
|
||||
data = room;
|
||||
} else if (path === '/api/game-session/results') { delete session.activeMatch; session.lastAppliedSeq = 1; data = { status: 'applied', stats: session.stats }; }
|
||||
else if (path === '/api/game-session/matches') { const body = JSON.parse(String(init?.body)); data = { ...body, seq: 2 }; }
|
||||
else if (path.startsWith('/api/rtc-config')) data = { iceServers: [] };
|
||||
return new Response(JSON.stringify({ ok: true, data }));
|
||||
};
|
||||
const storage = { getItem: (k: string) => mem.get(k) ?? null, setItem: (k: string, v: string) => { mem.set(k,v); }, removeItem: (k: string) => { mem.delete(k); } };
|
||||
const nodes = new Map<string, any>();
|
||||
const el = (id: string) => { if (!nodes.has(id)) nodes.set(id, { classList: { toggle() {}, add() {}, remove() {} }, addEventListener() {}, focus() {} }); return nodes.get(id); };
|
||||
const ctx: any = { Map, Date, Promise, JSON, URL, URLSearchParams, Number, crypto, ApiClientError, GAME_CHECKPOINT_KEY, GAME_ROOM_KEY, GUEST_TOKEN_KEY: 'guest', ROOM_POLL_MS: 1000, ACTIONS: {}, ACTION_IDS: [],
|
||||
document: { getElementById: el, body: { dataset: {} }, querySelectorAll: () => [] }, location: { origin: 'http://local', search: '' }, sessionStorage: storage, localStorage: { getItem: () => null, setItem() {} }, navigator: {}, fighterArt: () => '',
|
||||
GAME_NAME: '쎄쎄쎄:기공대전', HOME_TELEPORT_APPEARANCE, createGameApi: () => createGameApi(storage, send),
|
||||
createClientAnalytics: () => ({ ready() {}, screen() {}, gameFinished() {}, gameStarted() {}, error() {}, click() {} }),
|
||||
createResultReporter: (api: any) => ({ destroy() {}, resumePending: async () => {}, submit: api.record }),
|
||||
createMatchView: () => ({ update() {}, destroy() {} }), createPeer: () => ({}),
|
||||
createLobbyFeatures: () => ({ initialize: async () => false, setReady() {}, active: false }),
|
||||
createMatchController: (config: any) => { launched.push(config.activeMatch.matchId); return { subscribe() {}, start: async () => {}, view: () => ({ matchId: config.activeMatch.matchId }), destroy() {} }; },
|
||||
setTimeout(fn: () => void) { polls.set(++pollId, fn); return pollId; }, clearTimeout(id: number) { polls.delete(id); },
|
||||
};
|
||||
vm.createContext(ctx); vm.runInContext(compiled + '\nglobalThis.review={leave,boot,inspect:()=>({ready,currentScreen,currentRoom})};', ctx); await flush();
|
||||
return { ctx, calls, polls, room, mem, launched, open: () => open };
|
||||
}
|
||||
test('already-expired room does not trap a restored result on exit', async () => {
|
||||
const h = await setup('expired'); await h.ctx.review.leave();
|
||||
assert.equal(h.ctx.review.inspect().currentScreen, 'home'); assert.equal(h.mem.has(GAME_ROOM_KEY), false);
|
||||
});
|
||||
test('orphan registration recovery closes the persisted room membership before forgetting it', async () => {
|
||||
const h = await setup('orphan'); assert.equal(h.open(), false);
|
||||
assert.ok(h.calls.includes('/api/rooms/r/leave')); assert.equal(h.ctx.review.inspect().currentScreen, 'home');
|
||||
});
|
||||
test('refresh restores HTTP rematch consent polling and starts an advanced round', async () => {
|
||||
const h = await setup('rematch-wait'); assert.ok(h.calls.includes('/api/rooms/r')); assert.equal(h.polls.size, 1);
|
||||
h.room.round = 2; h.room.currentMatchId = 'new-match'; h.room.rematch = [false, false];
|
||||
[...h.polls.values()][0](); await flush(); assert.deepEqual(h.launched, ['new-match']);
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createMatchController } from '../src/client/match-controller.js';
|
||||
import { emptyPlayerStats, type ResultSubmission } from '../src/shared/p2p-types.js';
|
||||
import { ManualClock } from './helpers.js';
|
||||
|
||||
function harness() {
|
||||
const clock = new ManualClock(); clock.time = 1000;
|
||||
const recorded: ResultSubmission[] = [], memory = new Map<string, string>();
|
||||
const stats = emptyPlayerStats();
|
||||
const controller = createMatchController({ activeMatch: { matchId: 'local-bot', seq: 1, mode: 'bot' }, gameSessionId: 'session', selfIndex: 0, clock, random: () => 0,
|
||||
storage: { getItem: k => memory.get(k) ?? null, setItem: (k, v) => { memory.set(k, v); }, removeItem: k => { memory.delete(k); } },
|
||||
reporter: { submit: async data => { recorded.push(data); return { status: 'applied', stats }; } },
|
||||
});
|
||||
return { controller, clock, recorded, memory };
|
||||
}
|
||||
test('local bot runs countdown, turns, result and persistence without Socket.IO', async () => {
|
||||
const h = harness(); const seen: string[] = []; h.controller.subscribe(s => seen.push(s.phase));
|
||||
try {
|
||||
await h.controller.start(); await h.clock.advance(3000);
|
||||
assert.equal(h.controller.view().phase, 'selecting');
|
||||
assert.equal(await h.controller.submitAction('blast'), false);
|
||||
assert.equal(await h.controller.submitAction('charge'), true);
|
||||
await h.clock.advance(4000); assert.deepEqual(h.controller.view().energy, [1, 1]);
|
||||
await h.clock.advance(1200); assert.equal(await h.controller.submitAction('blast'), true);
|
||||
await h.clock.advance(4000);
|
||||
assert.equal(h.controller.view().finished?.winnerIndex, 0);
|
||||
assert.equal(h.controller.view().saveStatus, 'saved');
|
||||
assert.equal(h.recorded.length, 1); assert.equal(h.recorded[0].outcome, 'win');
|
||||
assert.ok(seen.includes('countdown')); assert.ok(seen.includes('revealing'));
|
||||
} finally { h.controller.destroy(); }
|
||||
assert.equal(h.clock.timers.size, 0);
|
||||
});
|
||||
test('timeout records a loss once and local leave records forfeit', async () => {
|
||||
const h = harness();
|
||||
try { await h.controller.start(); await h.clock.advance(7000); assert.equal(h.recorded[0].outcome, 'loss'); assert.equal(h.recorded[0].reason, 'timeout'); }
|
||||
finally { h.controller.destroy(); }
|
||||
const other = harness();
|
||||
try { await other.controller.start(); await other.controller.leave(); assert.equal(other.recorded[0].reason, 'forfeit'); assert.equal(other.recorded[0].outcome, 'loss'); }
|
||||
finally { other.controller.destroy(); }
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createMatchEngine } from '../src/shared/match-engine.js';
|
||||
|
||||
function setup(mode: 'bot' | 'friend' = 'friend') {
|
||||
let now = 1000;
|
||||
const engine = createMatchEngine({ matchId: 'test-match', mode, selfIndex: 0, clock: { now: () => now }, random: () => 0 });
|
||||
return { engine, at: (value: number) => { now = value; } };
|
||||
}
|
||||
test('countdown and turn deadlines gate actions without a server', () => {
|
||||
const { engine, at } = setup();
|
||||
engine.start(1000);
|
||||
assert.equal(engine.view().phase, 'countdown');
|
||||
assert.equal(engine.lockAction('charge'), false);
|
||||
at(3999); assert.equal(engine.beginTurn(), false);
|
||||
at(4000); assert.equal(engine.beginTurn(), true);
|
||||
assert.equal(engine.view().deadline, 8000);
|
||||
assert.equal(engine.lockAction('blast'), false);
|
||||
assert.equal(engine.lockAction('charge'), true);
|
||||
assert.equal(engine.lockAction('guard'), false);
|
||||
at(8000); assert.equal(engine.lockAction('charge'), false);
|
||||
engine.resolve(['charge', 'charge']);
|
||||
assert.deepEqual(engine.view().energy, [1, 1]);
|
||||
assert.equal(engine.view().phase, 'revealing');
|
||||
at(9199); assert.equal(engine.beginTurn(), false);
|
||||
at(9200); assert.equal(engine.beginTurn(), true);
|
||||
assert.equal(engine.view().turnId, 2);
|
||||
});
|
||||
test('a turn resolves once and a hit ends the game', () => {
|
||||
const { engine, at } = setup(); engine.start(1000); at(4000); engine.beginTurn(); at(8000);
|
||||
engine.resolve(['charge', 'charge']);
|
||||
assert.throws(() => engine.resolve(['charge', 'charge']), /NOT_SELECTING/);
|
||||
at(9200); engine.beginTurn(); at(13200); engine.resolve(['blast', 'charge']);
|
||||
assert.equal(engine.view().finished?.winnerIndex, 0);
|
||||
assert.equal(engine.view().finished?.reason, 'hit');
|
||||
assert.equal(engine.lockAction('charge'), false);
|
||||
assert.equal(engine.beginTurn(), false);
|
||||
});
|
||||
test('only verified missing choices count as timeouts; technical abort leaves outcome separate', () => {
|
||||
const { engine, at } = setup(); engine.start(1000); at(4000); engine.beginTurn(); at(8000);
|
||||
engine.resolve([null, 'charge']);
|
||||
assert.equal(engine.view().finished?.winnerIndex, 1);
|
||||
assert.equal(engine.view().finished?.reason, 'timeout');
|
||||
const other = setup(); other.engine.start(1000); other.at(4000); other.engine.beginTurn(); other.at(8000);
|
||||
other.engine.resolve([null, null]);
|
||||
assert.equal(other.engine.view().finished?.reason, 'double_timeout');
|
||||
const aborted = setup(); aborted.engine.abort('connection_failed');
|
||||
assert.equal(aborted.engine.view().aborted, true);
|
||||
assert.equal(aborted.engine.view().phase, 'finished');
|
||||
});
|
||||
test('checkpoints are copies and restore cannot overwrite match identity', () => {
|
||||
const { engine, at } = setup(); engine.start(1000); at(4000); engine.beginTurn(); engine.lockAction('charge');
|
||||
const snapshot = engine.checkpoint(); snapshot.state.energy[0] = 99;
|
||||
assert.equal(engine.view().energy[0], 0);
|
||||
const clean = engine.checkpoint();
|
||||
const resumed = setup(); resumed.at(4000); resumed.engine.restore(clean);
|
||||
assert.equal(resumed.engine.view().ownAction, 'charge');
|
||||
assert.throws(() => resumed.engine.restore({ ...clean, matchId: 'other' }), /MATCH_MISMATCH/);
|
||||
});
|
||||
test('bot commits from public energy before human submission', () => {
|
||||
const { engine, at } = setup('bot'); engine.start(1000); at(4000); engine.beginTurn();
|
||||
assert.equal(engine.botAction(), 'charge');
|
||||
engine.lockAction('guard'); assert.equal(engine.botAction(), 'charge');
|
||||
at(8000); engine.resolve([engine.view().ownAction, engine.botAction()]);
|
||||
assert.deepEqual(engine.view().energy, [0, 1]);
|
||||
});
|
||||
test('confirmed reveal timing and recovery keep energy while discarding an unconfirmed choice', () => {
|
||||
const { engine, at } = setup(); engine.start(1000); at(4000); engine.beginTurn(); at(8000);
|
||||
engine.resolve(['charge', 'charge']); engine.present(8500);
|
||||
assert.equal(engine.view().reveal?.revealEndsAt, 9700);
|
||||
const confirmed = engine.checkpoint(); at(9700); engine.beginTurn(); engine.lockAction('blast');
|
||||
at(11000); engine.resumeTurn(confirmed, 11000);
|
||||
assert.deepEqual(engine.view().energy, [1, 1]); assert.equal(engine.view().turnId, 2);
|
||||
assert.equal(engine.view().ownAction, null); assert.equal(engine.view().deadline, 15000);
|
||||
});
|
||||
|
||||
test('rollback and recovery keep published revisions increasing so the view accepts restored states', () => {
|
||||
const { engine, at } = setup(); engine.start(); const confirmed = engine.checkpoint();
|
||||
at(4000); engine.beginTurn(); engine.lockAction('charge'); engine.presence(false);
|
||||
const before = engine.view().version;
|
||||
engine.restore(confirmed); engine.abort('connection_failed');
|
||||
assert.ok(engine.view().version > before);
|
||||
const other = setup(); other.engine.start(); const start = other.engine.checkpoint();
|
||||
other.at(4000); other.engine.beginTurn(); other.engine.lockAction('charge'); other.engine.presence(false);
|
||||
const previous = other.engine.view().version;
|
||||
other.engine.resumeTurn(start, 5000);
|
||||
assert.ok(other.engine.view().version > previous);
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createMatchController } from '../src/client/match-controller.js';
|
||||
import { emptyPlayerStats, type PeerMessage, type ResultSubmission } from '../src/shared/p2p-types.js';
|
||||
import { ManualClock } from './helpers.js';
|
||||
import type { PeerState } from '../src/client/peer.js';
|
||||
|
||||
test('failed rematch request remains retryable', async () => {
|
||||
let attempts = 0;
|
||||
const h = setup(async () => { if (++attempts === 1) throw new Error('offline'); }); const [a, b] = h.controllers;
|
||||
try {
|
||||
await Promise.all(h.controllers.map(c => c.start())); await a.leave(); await h.clock.advance(1);
|
||||
assert.equal(a.view().finished?.reason, 'forfeit'); assert.equal(b.view().finished?.winnerIndex, 1);
|
||||
await assert.rejects(a.rematch(), /offline/); assert.equal(a.view().rematch[0], false);
|
||||
await a.rematch(); assert.equal(attempts, 2);
|
||||
} finally { h.close(); }
|
||||
});
|
||||
|
||||
class Wire {
|
||||
epoch = 0; connected = false; remote?: Wire; drop = false;
|
||||
intercept?: (message: PeerMessage) => boolean;
|
||||
messages = new Set<(m: PeerMessage) => void>(); states = new Set<(s: PeerState) => void>();
|
||||
async connect() { this.connected = true; }
|
||||
send(message: PeerMessage) {
|
||||
if (!this.connected) throw new Error('DISCONNECTED');
|
||||
if (this.intercept?.(message)) return;
|
||||
if (!this.drop) queueMicrotask(() => { if (this.remote?.connected) for (const h of this.remote.messages) h(structuredClone(message)); });
|
||||
}
|
||||
onMessage(h: (m: PeerMessage) => void) { this.messages.add(h); return () => { this.messages.delete(h); }; }
|
||||
onState(h: (s: PeerState) => void) { this.states.add(h); return () => { this.states.delete(h); }; }
|
||||
async restart(epoch = this.epoch + 1) { this.epoch = epoch; this.connected = true; }
|
||||
close() { this.connected = false; }
|
||||
disconnect() { this.connected = false; for (const h of this.states) h('disconnected'); }
|
||||
}
|
||||
function setup(onRematch?: () => Promise<void>, guestClockOffset = 0) {
|
||||
const clock = new ManualClock(); clock.time = 1000;
|
||||
const a = new Wire(), b = new Wire(); a.remote = b; b.remote = a;
|
||||
const records: ResultSubmission[][] = [[], []];
|
||||
const storages: any[] = [];
|
||||
const controllers = [a, b].map((peer, slot) => {
|
||||
const memory = new Map<string, string>();
|
||||
const storage = { getItem: (k: string) => memory.get(k) ?? null, setItem: (k: string, v: string) => { memory.set(k, v); }, removeItem: (k: string) => { memory.delete(k); } }; storages.push(storage);
|
||||
const localClock = slot === 1 ? { now: () => clock.now() + guestClockOffset, setTimeout: clock.setTimeout, clearTimeout: clock.clearTimeout } : clock;
|
||||
return createMatchController({ activeMatch: { matchId: 'friend-match', seq: 1, mode: 'friend', roomId: 'r' }, gameSessionId: `session-${slot}`, selfIndex: slot as 0 | 1, clock: localClock, peer, onRematch,
|
||||
reporter: { submit: async data => { records[slot].push(data); return { status: 'applied', stats: emptyPlayerStats() }; } },
|
||||
storage,
|
||||
});
|
||||
});
|
||||
return { clock, a, b, controllers, records, storages, close: () => controllers.forEach(c => c.destroy()) };
|
||||
}
|
||||
test('two peers agree turns and record complementary results without server turn events', async () => {
|
||||
const h = setup(); const [a, b] = h.controllers;
|
||||
try {
|
||||
await Promise.all(h.controllers.map(c => c.start())); await h.clock.advance(3000);
|
||||
assert.equal(a.view().phase, 'selecting'); assert.equal(b.view().phase, 'selecting');
|
||||
await a.submitAction('charge'); await b.submitAction('charge'); await h.clock.advance(4000);
|
||||
assert.deepEqual(a.view().energy, [1, 1]); assert.deepEqual(b.view().energy, [1, 1]);
|
||||
await h.clock.advance(1200); await a.submitAction('blast'); await b.submitAction('charge'); await h.clock.advance(4000);
|
||||
assert.equal(h.records[0][0]?.outcome, 'win'); assert.equal(h.records[1][0]?.outcome, 'loss');
|
||||
assert.equal(h.records[0].length, 1); assert.equal(h.records[1].length, 1);
|
||||
} finally { h.close(); }
|
||||
});
|
||||
|
||||
test('loss of final presentation after digest acknowledgements preserves both agreed results', async () => {
|
||||
const h = setup(); const [a, b] = h.controllers;
|
||||
try {
|
||||
await Promise.all(h.controllers.map(c => c.start())); await h.clock.advance(3000);
|
||||
await a.submitAction('charge'); await b.submitAction('charge'); await h.clock.advance(5200);
|
||||
await a.submitAction('blast'); await b.submitAction('charge');
|
||||
h.a.intercept = m => { if (m.type !== 'turn:present') return false; queueMicrotask(() => { h.a.drop = h.b.drop = true; h.a.disconnect(); h.b.disconnect(); }); return true; };
|
||||
await h.clock.advance(14000);
|
||||
assert.deepEqual(h.records.map(r => r.map(x => x.outcome)), [['win'], ['loss']]);
|
||||
} finally { h.close(); }
|
||||
});
|
||||
|
||||
test('crossed explicit leave requests produce one draw per player instead of two wins', async () => {
|
||||
const h = setup();
|
||||
try {
|
||||
await Promise.all(h.controllers.map(c => c.start()));
|
||||
await Promise.all(h.controllers.map(c => c.leave())); await h.clock.advance(1);
|
||||
assert.deepEqual(h.records.map(r => r.map(x => x.outcome)), [['draw'], ['draw']]);
|
||||
} finally { h.close(); }
|
||||
});
|
||||
test('recovery starts a fresh epoch and ignores messages from the previous connection', async () => {
|
||||
const h = setup(); const [a, b] = h.controllers;
|
||||
try {
|
||||
await Promise.all(h.controllers.map(c => c.start())); await h.clock.advance(3000); await a.submitAction('charge');
|
||||
h.a.disconnect(); h.b.disconnect(); await h.clock.advance(100);
|
||||
assert.equal(h.a.epoch, 1); assert.equal(h.b.epoch, 1);
|
||||
assert.equal(a.view().ownAction, null); assert.equal(b.view().ownAction, null);
|
||||
assert.equal(h.records[0].length, 0);
|
||||
h.a.send({ protocolVersion: 1, rulesVersion: '1', matchId: 'friend-match', connectionEpoch: 0, turnId: 1, type: 'turn:commit', digest: '0'.repeat(64), slot: 0 });
|
||||
await h.clock.advance(1); assert.equal(b.view().phase, 'selecting');
|
||||
} finally { h.close(); }
|
||||
});
|
||||
|
||||
test('peers whose wall clocks differ by five seconds agree on deadline and timeout result', async () => {
|
||||
const h = setup(undefined, 5000); const [a, b] = h.controllers;
|
||||
try {
|
||||
await Promise.all(h.controllers.map(c => c.start())); await h.clock.advance(3000);
|
||||
assert.equal(a.view().deadline, b.view().deadline); assert.equal(a.view().clockNow, b.view().clockNow);
|
||||
await a.submitAction('charge'); await h.clock.advance(4000);
|
||||
assert.equal(h.records[0][0]?.outcome, 'win'); assert.equal(h.records[1][0]?.outcome, 'loss');
|
||||
assert.equal(a.view().finished?.reason, 'timeout'); assert.equal(b.view().finished?.reason, 'timeout');
|
||||
} finally { h.close(); }
|
||||
});
|
||||
test('unrecoverable transport interruption aborts rather than inventing two wins', async () => {
|
||||
const h = setup();
|
||||
try {
|
||||
await Promise.all(h.controllers.map(c => c.start())); await h.clock.advance(3000);
|
||||
h.a.drop = h.b.drop = true; h.a.disconnect(); h.b.disconnect();
|
||||
await new Promise(r => setImmediate(r)); // drain already-delivered messages before advancing the recovery clock
|
||||
await h.clock.advance(10000);
|
||||
assert.equal(h.records[0][0]?.outcome, 'aborted'); assert.equal(h.records[1][0]?.outcome, 'aborted');
|
||||
} finally { h.close(); }
|
||||
});
|
||||
|
||||
test('refresh of a mutually confirmed final result saves without reconnecting to the opponent', async () => {
|
||||
const h = setup();
|
||||
try {
|
||||
await Promise.all(h.controllers.map(c => c.start())); await h.clock.advance(7000);
|
||||
const { GAME_CHECKPOINT_KEY } = await import('../constants/recording.js');
|
||||
const saved = JSON.parse(h.storages[0].getItem(GAME_CHECKPOINT_KEY));
|
||||
saved.checkpoint.state.finished.saved = false; saved.checkpoint.state.saveStatus = 'pending';
|
||||
h.storages[0].setItem(GAME_CHECKPOINT_KEY, JSON.stringify(saved));
|
||||
h.close(); let recorded = 0;
|
||||
const restored = createMatchController({ activeMatch: saved.activeMatch, gameSessionId: 'session-0', selfIndex: 0, clock: h.clock, storage: h.storages[0],
|
||||
reporter: { submit: async data => { recorded++; assert.equal(data.outcome, 'draw'); return { status: 'duplicate', stats: emptyPlayerStats() }; } } });
|
||||
try { await restored.start(); assert.equal(recorded, 1); assert.equal(restored.view().saveStatus, 'saved'); }
|
||||
finally { restored.destroy(); }
|
||||
} finally { h.close(); }
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
// Diagnostic harness for real-browser testing; excluded from production bundles.
|
||||
(() => {
|
||||
const NativePeer = window.RTCPeerConnection, NativeSocket = window.WebSocket;
|
||||
const trace = []; let sockets = 0, nextPeer = 0;
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const controls = document.createElement('aside');
|
||||
controls.style.cssText = 'position:fixed;right:0;top:0;z-index:100;background:white;color:black;padding:4px';
|
||||
controls.innerHTML = '<label>검증 입력 <select id="test-input"><option value="none">수동</option><option value="charge">충전 반복</option><option value="blast">충전 후 파</option><option value="guard">막기 반복</option></select></label>';
|
||||
document.body.append(controls);
|
||||
setInterval(() => {
|
||||
const mode = controls.querySelector('select').value;
|
||||
if (mode === 'none') return;
|
||||
const blast = document.querySelector('button[data-action="blast"]');
|
||||
const action = mode === 'blast' ? blast && !blast.disabled ? 'blast' : 'charge' : mode;
|
||||
const button = document.querySelector('button[data-action="' + action + '"]');
|
||||
if (button && !button.disabled && button.getClientRects().length) button.click();
|
||||
}, 100);
|
||||
});
|
||||
function report(event, details = {}) {
|
||||
trace.push({ event, ...details }); if (trace.length > 80) trace.shift();
|
||||
console.info('RTC_TEST ' + JSON.stringify({ sockets, ...trace.at(-1) }));
|
||||
let output = document.getElementById('rtc-test');
|
||||
if (!output && document.body) { output = document.createElement('pre'); output.id = 'rtc-test'; output.style.cssText = 'position:fixed;right:0;bottom:0;max-height:110px;max-width:460px;overflow:auto;background:white;color:black;font:10px monospace;z-index:100;opacity:.85'; document.body.append(output); }
|
||||
if (output) output.textContent = JSON.stringify({ sockets, trace: trace.slice(-12) }, null, 1);
|
||||
}
|
||||
window.WebSocket = class extends NativeSocket {
|
||||
constructor(...args) { super(...args); this.addEventListener('open', () => { sockets++; report('socket-open'); }); this.addEventListener('close', () => { sockets--; report('socket-close'); }); }
|
||||
};
|
||||
window.RTCPeerConnection = class extends NativePeer {
|
||||
constructor(...args) {
|
||||
super(...args); this.testId = ++nextPeer; report('pc-created', { id: this.testId });
|
||||
for (const event of ['connectionstatechange', 'iceconnectionstatechange', 'signalingstatechange']) this.addEventListener(event, () => report(event, { id: this.testId, connection: this.connectionState, ice: this.iceConnectionState, signaling: this.signalingState }));
|
||||
this.addEventListener('datachannel', e => this.monitor(e.channel));
|
||||
}
|
||||
createDataChannel(...args) { const channel = super.createDataChannel(...args); this.monitor(channel); return channel; }
|
||||
setConfiguration(value) { try { super.setConfiguration(value); report('configuration-updated', { id: this.testId }); } catch (error) { report('configuration-error', { message: error.message, state: this.connectionState, before: this.getConfiguration().bundlePolicy, after: value.bundlePolicy }); throw error; } }
|
||||
restartIce() { try { super.restartIce(); report('ice-restart', { id: this.testId }); } catch (error) { report('restart-error', { message: error.message, state: this.connectionState }); throw error; } }
|
||||
monitor(channel) {
|
||||
for (const event of ['open', 'close', 'error']) channel.addEventListener(event, () => report('channel-' + event, { id: this.testId }));
|
||||
channel.addEventListener('message', event => { try { const m = JSON.parse(event.data); report('message', { id: this.testId, type: m.type, epoch: m.connectionEpoch ?? m.epoch, turn: m.turnId }); } catch {} });
|
||||
channel.addEventListener('open', () => { void this.getStats().then(stats => {
|
||||
for (const item of stats.values()) if (item.type === 'candidate-pair' && item.state === 'succeeded') report('candidate', { id: this.testId, local: stats.get(item.localCandidateId)?.candidateType, remote: stats.get(item.remoteCandidateId)?.candidateType, rtt: item.currentRoundTripTime });
|
||||
}); });
|
||||
}
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,39 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createTurnExchange } from '../src/shared/peer-protocol.js';
|
||||
import type { PeerMessage } from '../src/shared/p2p-types.js';
|
||||
import type { ActionId, Pair } from '../src/shared/types.js';
|
||||
|
||||
function pair() {
|
||||
const toA: PeerMessage[] = [], toB: PeerMessage[] = [], resolvedA: Pair<ActionId | null>[] = [], resolvedB: Pair<ActionId | null>[] = [];
|
||||
const base = { matchId: 'match', epoch: 0, turnId: 1, energy: [0, 0] as Pair<number> };
|
||||
const a = createTurnExchange({ ...base, slot: 0, send: m => toB.push(m), onResolved: r => resolvedA.push(r) });
|
||||
const b = createTurnExchange({ ...base, slot: 1, send: m => toA.push(m), onResolved: r => resolvedB.push(r) });
|
||||
const pump = async () => { for (let i = 0; i < 20 && (toA.length || toB.length); i++) { while (toA.length) await a.receive(toA.shift()!); while (toB.length) await b.receive(toB.shift()!); } };
|
||||
return { a, b, toA, toB, resolvedA, resolvedB, pump };
|
||||
}
|
||||
test('choices stay secret until both commitments and resolve identically exactly once', async () => {
|
||||
const h = pair(); await h.a.commit('charge');
|
||||
assert.equal(h.toB.length, 1); assert.equal(h.toB[0].type, 'turn:commit');
|
||||
assert.equal('action' in h.toB[0], false); await h.b.receive(h.toB.shift()!);
|
||||
assert.equal(h.toA.length, 0);
|
||||
await h.b.commit('guard'); const commit = h.toA[0]; await h.pump();
|
||||
assert.deepEqual(h.resolvedA, [['charge', 'guard']]); assert.deepEqual(h.resolvedB, h.resolvedA);
|
||||
await h.a.receive(commit); assert.equal(h.resolvedA.length, 1);
|
||||
});
|
||||
test('missing input is committed as null and is distinct from missing network packets', async () => {
|
||||
const h = pair(); await h.a.commit(null); await h.b.commit('charge'); await h.pump();
|
||||
assert.deepEqual(h.resolvedA, [[null, 'charge']]);
|
||||
const silent = pair(); await silent.a.commit(null); assert.equal(silent.resolvedA.length, 0);
|
||||
});
|
||||
test('tampered reveal and changed commitment are rejected; stale epochs never resolve', async () => {
|
||||
const h = pair(); await h.a.commit('charge'); await h.b.commit('guard');
|
||||
const original = h.toA[0];
|
||||
await h.a.receive({ ...original, connectionEpoch: 99 }); assert.equal(h.toB.length, 1);
|
||||
await h.a.receive(h.toA.shift()!);
|
||||
await h.b.receive(h.toB.shift()!);
|
||||
const reveal = h.toA.find(m => m.type === 'turn:reveal')!;
|
||||
await assert.rejects(h.a.receive({ ...reveal, action: 'charge' } as PeerMessage), /COMMITMENT_MISMATCH/);
|
||||
await assert.rejects(h.a.receive({ ...original, digest: '0'.repeat(64) } as PeerMessage), /COMMITMENT_CHANGED/);
|
||||
assert.equal(h.resolvedA.length, 0);
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createPeer } from '../src/client/peer.js';
|
||||
import { PROTOCOL_VERSION } from '../constants/webrtc.js';
|
||||
import { RULES_VERSION } from '../constants/gameplay.js';
|
||||
|
||||
class Signal {
|
||||
handlers = new Map<string, Function[]>(); peer?: Signal; connected = false; disconnected = false;
|
||||
on(name: string, fn: Function) { this.handlers.set(name, [...this.handlers.get(name) ?? [], fn]); return this; }
|
||||
once(name: string, fn: Function) { return this.on(name, fn); }
|
||||
fire(name: string, data?: any) { for (const fn of this.handlers.get(name) ?? []) fn(data); }
|
||||
connect() { this.connected = true; queueMicrotask(() => { this.fire('connect'); if (this.peer?.connected) { this.fire('signal:peers', { count: 2 }); this.peer.fire('signal:peers', { count: 2 }); } }); }
|
||||
disconnect() { this.disconnected = true; this.connected = false; return this; }
|
||||
timeout() { return this; }
|
||||
async emitWithAck(_event: string, packet: any) { queueMicrotask(() => this.peer?.fire('signal', packet)); return { ok: true }; }
|
||||
removeAllListeners() { this.handlers.clear(); return this; }
|
||||
}
|
||||
class Channel {
|
||||
readyState = 'connecting'; onopen?: () => void; onmessage?: (event: { data: string }) => void; onclose?: () => void;
|
||||
peer?: Channel;
|
||||
send(data: string) { queueMicrotask(() => this.peer?.onmessage?.({ data })); }
|
||||
close() { this.readyState = 'closed'; this.onclose?.(); }
|
||||
}
|
||||
class PC {
|
||||
connectionState = 'new'; localDescription: any; remoteDescription: any; channel?: Channel; peer?: PC;
|
||||
ondatachannel?: (event: any) => void; onicecandidate?: (event: any) => void; onconnectionstatechange?: () => void;
|
||||
createDataChannel(_label: string, options: any) { assert.equal(options.ordered, true); assert.equal(options.maxRetransmits, undefined); return this.channel = new Channel(); }
|
||||
async createOffer() { return { type: 'offer', sdp: 'offer' }; }
|
||||
async createAnswer() { return { type: 'answer', sdp: 'answer' }; }
|
||||
async setLocalDescription(value: any) { this.localDescription = value; }
|
||||
async setRemoteDescription(value: any) {
|
||||
this.remoteDescription = value;
|
||||
if (value.type === 'answer') {
|
||||
const remote = new Channel(); this.channel!.peer = remote; remote.peer = this.channel;
|
||||
this.peer!.channel = remote; this.peer!.ondatachannel?.({ channel: remote });
|
||||
this.connectionState = this.peer!.connectionState = 'connected';
|
||||
this.channel!.readyState = remote.readyState = 'open'; this.channel!.onopen?.(); remote.onopen?.();
|
||||
}
|
||||
}
|
||||
async addIceCandidate() {} restartIce() {} close() { this.connectionState = 'closed'; }
|
||||
async getStats() { return new Map(); }
|
||||
getConfiguration() { return { iceServers: [], bundlePolicy: 'max-bundle', rtcpMuxPolicy: 'require' }; }
|
||||
setConfiguration(value: any) { if (value.bundlePolicy !== 'max-bundle' || value.rtcpMuxPolicy !== 'require') throw new Error('INVALID_MODIFICATION'); }
|
||||
}
|
||||
test('peer connects reliable channel, closes signaling after mutual ready and keeps delivering data', async () => {
|
||||
const sa = new Signal(), sb = new Signal(); sa.peer = sb; sb.peer = sa;
|
||||
const pa = new PC(), pb = new PC(); pa.peer = pb; pb.peer = pa;
|
||||
const config = { matchId: 'm1', roomId: 'r1', epoch: 0, iceServers: [] };
|
||||
const a = createPeer({ ...config, slot: 0, createSignaling: () => sa as any, createConnection: () => pa as any });
|
||||
const b = createPeer({ ...config, slot: 1, createSignaling: () => sb as any, createConnection: () => pb as any });
|
||||
try {
|
||||
const messages: any[] = []; b.onMessage(m => messages.push(m));
|
||||
await Promise.all([a.connect(), b.connect()]);
|
||||
assert.equal(sa.disconnected, true); assert.equal(sb.disconnected, true);
|
||||
const message = { protocolVersion: PROTOCOL_VERSION, rulesVersion: RULES_VERSION, matchId: 'm1', connectionEpoch: 0, turnId: 1, type: 'turn:start' as const, startsAt: 1000 };
|
||||
a.send(message); await new Promise(r => setImmediate(r)); assert.deepEqual(messages, [message]);
|
||||
assert.equal(pa.connectionState, 'connected');
|
||||
} finally { a.close(); b.close(); }
|
||||
});
|
||||
|
||||
test('late ICE rejection after mutual ready does not invalidate a working data channel', async () => {
|
||||
const sa = new Signal(), sb = new Signal(); sa.peer = sb; sb.peer = sa;
|
||||
const pa = new PC(), pb = new PC(); pa.peer = pb; pb.peer = pa;
|
||||
const normal = sa.emitWithAck.bind(sa); let rejectIce!: (error: Error) => void;
|
||||
sa.emitWithAck = (event, packet) => packet.type === 'ice' ? new Promise((_resolve, reject) => { rejectIce = reject; }) : normal(event, packet);
|
||||
const a = createPeer({ matchId: 'late', roomId: 'r', slot: 0, iceServers: [], createSignaling: () => sa as any, createConnection: () => pa as any });
|
||||
const b = createPeer({ matchId: 'late', roomId: 'r', slot: 1, iceServers: [], createSignaling: () => sb as any, createConnection: () => pb as any });
|
||||
try {
|
||||
const connecting = a.connect(); pa.onicecandidate?.({ candidate: { toJSON: () => ({ candidate: 'test' }) } });
|
||||
await Promise.all([connecting, b.connect()]);
|
||||
rejectIce(new Error('PEER_NOT_READY')); await new Promise(r => setImmediate(r));
|
||||
assert.equal(a.connected, true); assert.equal(b.connected, true);
|
||||
} finally { a.close(); b.close(); }
|
||||
});
|
||||
|
||||
test('an ICE failure from the previous epoch cannot reject the new connection attempt', async () => {
|
||||
const sa = new Signal(), sb = new Signal(); sa.peer = sb; sb.peer = sa;
|
||||
const pa = new PC(), pb = new PC(); pa.peer = pb; pb.peer = pa;
|
||||
const normal = sa.emitWithAck.bind(sa); let rejectIce!: (error: Error) => void;
|
||||
sa.emitWithAck = (event, packet) => packet.type === 'ice' ? new Promise((_resolve, reject) => { rejectIce = reject; }) : normal(event, packet);
|
||||
const a = createPeer({ matchId: 'generation', roomId: 'r', slot: 0, iceServers: [], createSignaling: () => sa as any, createConnection: () => pa as any });
|
||||
const b = createPeer({ matchId: 'generation', roomId: 'r', slot: 1, iceServers: [], createSignaling: () => sb as any, createConnection: () => pb as any });
|
||||
try {
|
||||
const first = a.connect(); pa.onicecandidate?.({ candidate: { toJSON: () => ({ candidate: 'old' }) } });
|
||||
await Promise.all([first, b.connect()]);
|
||||
const recovery = a.restart(); let failed = false; void recovery.catch(() => { failed = true; });
|
||||
rejectIce(new Error('OLD_ICE')); await new Promise(r => setImmediate(r));
|
||||
assert.equal(failed, false); assert.equal(sa.connected, true);
|
||||
await Promise.all([recovery, b.restart()]); assert.equal(a.connected, true);
|
||||
} finally { a.close(); b.close(); }
|
||||
});
|
||||
|
||||
test('ICE restart refreshes servers without changing immutable connection configuration', async () => {
|
||||
const sa = new Signal(), sb = new Signal(); sa.peer = sb; sb.peer = sa;
|
||||
const pa = new PC(), pb = new PC(); pa.peer = pb; pb.peer = pa;
|
||||
const a = createPeer({ matchId: 'restart', roomId: 'r', slot: 0, iceServers: [], refreshIce: async () => [{ urls: 'stun:example.test' }], createSignaling: () => sa as any, createConnection: () => pa as any });
|
||||
const b = createPeer({ matchId: 'restart', roomId: 'r', slot: 1, iceServers: [], refreshIce: async () => [], createSignaling: () => sb as any, createConnection: () => pb as any });
|
||||
try {
|
||||
await Promise.all([a.connect(), b.connect()]);
|
||||
await Promise.all([a.restart(), b.restart()]);
|
||||
assert.equal(a.connected, true); assert.equal(b.connected, true); assert.equal(a.epoch, 1);
|
||||
} finally { a.close(); b.close(); }
|
||||
});
|
||||
|
||||
test('recovery replaces a closed data channel and can replace a connection that rejects ICE reconfiguration', async () => {
|
||||
const sa = new Signal(), sb = new Signal(); sa.peer = sb; sb.peer = sa;
|
||||
const pairs = Array.from({ length: 2 }, () => { const a = new PC(), b = new PC(); a.peer = b; b.peer = a; return [a, b]; });
|
||||
let ai = 0, bi = 0;
|
||||
pairs[0][0].setConfiguration = () => { throw new Error('InvalidModificationError'); };
|
||||
const a = createPeer({ matchId: 'fallback', roomId: 'r', slot: 0, iceServers: [], refreshIce: async () => [], createSignaling: () => sa as any, createConnection: () => pairs[ai++][0] as any });
|
||||
const b = createPeer({ matchId: 'fallback', roomId: 'r', slot: 1, iceServers: [], refreshIce: async () => [], createSignaling: () => sb as any, createConnection: () => pairs[bi++][1] as any });
|
||||
try {
|
||||
await Promise.all([a.connect(), b.connect()]);
|
||||
pairs[0][1].channel!.close();
|
||||
await Promise.all([a.restart(), b.restart()]);
|
||||
assert.equal(a.connected, true); assert.equal(b.connected, true);
|
||||
assert.equal(pairs[0][0].connectionState, 'closed'); assert.equal(pairs[0][1].connectionState, 'closed');
|
||||
assert.equal(ai, 2); assert.equal(bi, 2);
|
||||
} finally { a.close(); b.close(); }
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
// Local UI fixture only. This entry point is never compiled into the production server.
|
||||
import { createGameServer } from '../src/server/create-server.js';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { build } from 'esbuild';
|
||||
const clientDirectory = process.env.UI_PREVIEW_CLIENT ?? 'dist/client';
|
||||
const app = await createGameServer({ mongoUri: process.env.TEST_MONGODB_URI!, databaseName: 'dragon_ball_test_preview_20261012', clientDirectory, adminUser: 'preview', adminPassword: 'preview-test-only', environment: 'test' });
|
||||
// Test-only credentials stay on the fixture server; normal production auth is tested separately.
|
||||
const authorization = `Basic ${Buffer.from('preview:preview-test-only').toString('base64')}`;
|
||||
app.get('/__test/peer', async (_request, reply) => {
|
||||
const index = await readFile(clientDirectory + '/index.html', 'utf8');
|
||||
return reply.type('text/html; charset=utf-8').send(index.replace('<head>', '<head><script src="/__test/peer-monitor.js"></script>'));
|
||||
});
|
||||
app.get('/__test/peer-monitor.js', async (_request, reply) => reply.type('application/javascript').send(await readFile('tests/peer-browser-monitor.js', 'utf8')));
|
||||
app.get('/__test/admin', async (_request, reply) => {
|
||||
const response = await app.inject({ url: '/admin/analytics', headers: { authorization } });
|
||||
const bootstrap = '<script>const f=window.fetch;window.fetch=(u,o)=>f(typeof u==="string"&&u.startsWith("/api/admin/analytics/")?"/__test/admin-api/"+u.slice(21):u,o)</script>';
|
||||
return reply.type('text/html; charset=utf-8').send(response.body.replace('<head>', `<head>${bootstrap}`));
|
||||
});
|
||||
app.get('/__test/admin-api/*', async (request, reply) => {
|
||||
const tail = (request.params as { '*': string })['*'];
|
||||
const query = request.url.includes('?') ? request.url.slice(request.url.indexOf('?')) : '';
|
||||
const response = await app.inject({ url: `/api/admin/analytics/${tail}${query}`, headers: { authorization } });
|
||||
return reply.code(response.statusCode).type('application/json').send(response.body);
|
||||
});
|
||||
app.get('/__test/mobile', (request, reply) => {
|
||||
const query = request.query as { width?: string; height?: string };
|
||||
const width = query.width === '320' ? 320 : 390, height = query.height === '568' ? 568 : 844;
|
||||
return reply.type('text/html; charset=utf-8').send(`<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>모바일 검증</title><style>body{margin:0;background:#dfe4ec}iframe{width:${width}px;height:${height}px;border:0;display:block}</style><iframe title="모바일 게임" src="/"></iframe>`);
|
||||
});
|
||||
app.get('/__test/desktop', (_request, reply) => reply.type('text/html; charset=utf-8').send('<!doctype html><meta charset="utf-8"><title>PC 검증</title><style>body{margin:0;background:#dfe4ec}iframe{width:1280px;height:720px;border:0;display:block}</style><iframe title="PC 게임" src="/"></iframe>'));
|
||||
app.get('/__test/admin-mobile', (_request, reply) => reply.type('text/html; charset=utf-8').send('<!doctype html><meta charset="utf-8"><title>관리자 모바일 검증</title><style>body{margin:0;background:#dfe4ec}iframe{width:390px;height:844px;border:0;display:block}</style><iframe title="관리자 분석" src="/__test/admin"></iframe>'));
|
||||
app.get('/__test/scenarios', async (_request, reply) => {
|
||||
const index = await readFile(clientDirectory + '/index.html', 'utf8');
|
||||
const styles = index.match(/<link[^>]+rel="stylesheet"[^>]*>/g)?.join('') ?? '';
|
||||
return reply.type('text/html; charset=utf-8').send(`<!doctype html><html lang="ko"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>UI 상태 검증</title>${styles}<style>body>.app{min-height:calc(100svh - 60px)}#scenarios{display:flex;align-items:center;gap:8px;height:60px;padding:6px 12px;background:white;position:relative;z-index:20}#scenarios select{font:inherit;font-size:14px;border:1px solid #c5ceca;border-radius:8px;padding:8px;min-width:0;max-width:70%;height:44px}#scenarios button{height:44px;min-height:44px;padding:6px 18px}#scenarios+.app header{height:48px}</style></head><body class="game-page"><nav id="scenarios" aria-label="검증 시나리오"></nav><div class="app" data-screen="match"><header><span class="brand">드래곤볼</span><span>UI 상태 검증</span></header><main id="main"><section id="match"></section></main></div><script type="module" src="/__test/scenarios.js"></script></body></html>`);
|
||||
});
|
||||
app.get('/__test/scenarios.js', async (_request, reply) => {
|
||||
const bundle = await build({ entryPoints: ['tests/ui-scenarios.ts'], bundle: true, write: false, format: 'esm', platform: 'browser', loader: { '.css': 'empty' } });
|
||||
return reply.type('application/javascript').send(bundle.outputFiles[0].text);
|
||||
});
|
||||
app.get('/__test/scenarios-mobile', (request, reply) => {
|
||||
const query = request.query as { width?: string; height?: string };
|
||||
const width = query.width === '320' ? 320 : 390, height = query.height === '568' ? 568 : 844;
|
||||
return reply.type('text/html; charset=utf-8').send(`<!doctype html><meta charset="utf-8"><title>모바일 상태 검증</title><style>body{margin:0;background:#dfe4ec}iframe{width:${width}px;height:${height}px;border:0;display:block}</style><iframe title="모바일 UI 상태" src="/__test/scenarios"></iframe>`);
|
||||
});
|
||||
app.get('/__test/scenarios-desktop', (_request, reply) => reply.type('text/html; charset=utf-8').send('<!doctype html><meta charset="utf-8"><title>PC 상태 검증</title><style>body{margin:0;background:#dfe4ec}iframe{width:1280px;height:720px;border:0;display:block}</style><iframe title="PC UI 상태" src="/__test/scenarios"></iframe>'));
|
||||
const port = Number(process.env.UI_PREVIEW_PORT ?? 3001);
|
||||
await app.listen({ host: '127.0.0.1', port });
|
||||
console.log(`http://127.0.0.1:${port}`);
|
||||
for (const signal of ['SIGINT', 'SIGTERM'] as const) process.once(signal, () => { void app.close(); });
|
||||
@@ -0,0 +1,103 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import Fastify from 'fastify';
|
||||
import type { Db } from 'mongodb';
|
||||
import { registerGameApi, registerRoomApi } from '../src/server/game-api.js';
|
||||
import type { GameSessionStore } from '../src/server/game-session-store.js';
|
||||
import { createRankedService } from '../src/server/ranked-service.js';
|
||||
import type { RoomStore } from '../src/server/rooms.js';
|
||||
import type { Signaling } from '../src/server/signaling.js';
|
||||
import { ManualClock } from './helpers.js';
|
||||
|
||||
function deferred() {
|
||||
let resolve!: () => void;
|
||||
const promise = new Promise<void>(done => { resolve = done; });
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
// Only the persistence boundary is fake. Queue ownership, serialization, pairing,
|
||||
// availability checks and HTTP routing all run through the production code.
|
||||
function memoryDatabase(now: number) {
|
||||
const records: Record<string, Record<string, any>[]> = {
|
||||
game_sessions: [
|
||||
{ _id: 'session-a', playerId: 'player-a', expiresAt: new Date(now + 60000) },
|
||||
{ _id: 'session-b', playerId: 'player-b', expiresAt: new Date(now + 60000) },
|
||||
],
|
||||
player_profiles: [], ranked_matches: [], rooms: [],
|
||||
};
|
||||
const matches = (doc: Record<string, any>, query: Record<string, any> = {}) =>
|
||||
Object.entries(query).every(([key, value]) => {
|
||||
if (value && typeof value === 'object' && '$gt' in value) return doc[key] > value.$gt;
|
||||
if (value && typeof value === 'object' && '$in' in value) {
|
||||
return (Array.isArray(doc[key]) ? doc[key] : [doc[key]]).some(item => value.$in.includes(item));
|
||||
}
|
||||
return Array.isArray(doc[key]) ? doc[key].includes(value) : doc[key] === value;
|
||||
});
|
||||
const db = { collection(name: string) {
|
||||
const data = records[name];
|
||||
return {
|
||||
async createIndex() {},
|
||||
async findOne(query: Record<string, any>) { return data.find(doc => matches(doc, query)) ?? null; },
|
||||
find(query?: Record<string, any>) {
|
||||
const rows = data.filter(doc => matches(doc, query));
|
||||
return {
|
||||
sort() { return this; }, limit() { return this; },
|
||||
async next() { return rows[0] ?? null; }, async toArray() { return rows; },
|
||||
async *[Symbol.asyncIterator]() { yield* rows; },
|
||||
};
|
||||
},
|
||||
async updateOne(query: Record<string, any>, update: Record<string, any>) {
|
||||
const doc = data.find(row => matches(row, query));
|
||||
if (doc) Object.assign(doc, update.$set ?? {});
|
||||
else data.push({ ...query, ...update.$setOnInsert, ...update.$set });
|
||||
},
|
||||
};
|
||||
} } as unknown as Db;
|
||||
return { db, records };
|
||||
}
|
||||
|
||||
for (const url of ['/api/game-session/matches', '/api/rooms', '/api/rooms/join']) {
|
||||
test(`${url} reserves the player until its write completes before ranked pairing`, { timeout: 5000 }, async () => {
|
||||
const clock = new ManualClock(), { db, records } = memoryDatabase(clock.now());
|
||||
const ranked = await createRankedService(db, clock, 'test'), app = Fastify();
|
||||
const entered = deferred(), release = deferred();
|
||||
const regularMatch = { matchId: 'bot-match', requestId: 'request-a', mode: 'bot', expectedPreviousSeq: 0 };
|
||||
const room = { id: 'friend-room' };
|
||||
const write = async () => {
|
||||
entered.resolve();
|
||||
await release.promise;
|
||||
if (url === '/api/game-session/matches') records.game_sessions[0].activeMatch = regularMatch;
|
||||
else records.rooms.push({ _id: room.id, members: ['session-a'], open: true, expiresAt: new Date(clock.now() + 60000) });
|
||||
return url === '/api/game-session/matches' ? regularMatch : room;
|
||||
};
|
||||
const sessions = {
|
||||
async get() { return { gameSessionId: 'session-a', playerId: 'player-a' }; },
|
||||
register: write,
|
||||
} as unknown as GameSessionStore;
|
||||
const rooms = { create: write, join: write } as unknown as RoomStore;
|
||||
registerGameApi(app, sessions, undefined, ranked);
|
||||
registerRoomApi(app, sessions, rooms, { async notifyRoom() {} } as unknown as Signaling, ranked);
|
||||
try {
|
||||
await ranked.join('player-b', 'session-b');
|
||||
const regular = app.inject({ method: 'POST', url,
|
||||
headers: { authorization: `Bearer ${'a'.repeat(43)}`, 'x-game-session': 'session-a' },
|
||||
payload: url === '/api/game-session/matches' ? regularMatch : { requestId: 'request-a', code: 'ABCDEF' },
|
||||
}).then(response => response);
|
||||
await entered.promise;
|
||||
const pairing = ranked.join('player-a', 'session-a').then(
|
||||
status => ({ status, error: undefined }), error => ({ status: undefined, error }),
|
||||
);
|
||||
// Drain queued work while the regular-mode write is explicitly held open.
|
||||
// With the old check-then-write guard, ranked pairing completes in this gap.
|
||||
await new Promise<void>(resolve => setImmediate(resolve));
|
||||
release.resolve();
|
||||
assert.equal((await regular).statusCode, 200);
|
||||
const outcome = await pairing;
|
||||
assert.equal(outcome.error?.code, 'MATCH_PENDING', `ranked entered during the write: ${outcome.status?.status}`);
|
||||
assert.equal((await ranked.status('player-b', 'session-b')).status, 'queued');
|
||||
} finally {
|
||||
release.resolve();
|
||||
await app.close(); await ranked.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,547 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { randomBytes, randomUUID } from 'node:crypto';
|
||||
import Fastify from 'fastify';
|
||||
import { MongoClient } 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 } 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 };
|
||||
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 s.join('player-b', 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);
|
||||
await assert.rejects(s.cancel('player-a', a.gameSessionId), /MATCH_PENDING/);
|
||||
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 f.service.join('player-b', 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('profiles keep generated names and validate unlocked appearances, persistence and pairing snapshots', integration, async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
const s = f.service;
|
||||
const original = await s.profile('player-a');
|
||||
assert.deepEqual(original.appearance, normalizeAppearance({ characterId: 0, colorId: 'white' }));
|
||||
assert.deepEqual(original.unlockedEquipment, []);
|
||||
assert.match(original.displayName, /^[가-힣]+-[0-9]{4}$/);
|
||||
for (const displayName of ['손오공', '광고사이트', '<img>', null, undefined]) {
|
||||
await assert.rejects(s.updateProfile('player-a', { displayName } as any), /PROFILE_NAME_READONLY/);
|
||||
}
|
||||
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 s.join('player-b', 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 migrates old custom and default names including nonvisiting leaderboard players exactly once', 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 f.service.join('player-b', 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 } });
|
||||
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);
|
||||
for (const name of names) assert.match(name, /^[가-힣]+-[0-9]{4}$/);
|
||||
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, 1);
|
||||
} 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 s.join('player-b', 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 s.join('player-b', 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 f.service.join('player-b', 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 s.join('player-b', 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 s.join('player-b', 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);
|
||||
await s.action('player-b', b.gameSessionId, id, 1, 'charge');
|
||||
await clock.advance(TURN_DURATION_MS);
|
||||
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');
|
||||
await s.action('player-b', b.gameSessionId, id, 2, 'charge');
|
||||
await clock.advance(TURN_DURATION_MS);
|
||||
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 f.service.join('player-b', f.b.gameSessionId)).match!.matchId;
|
||||
await f.clock.advance(MATCH_COUNTDOWN_MS + TURN_DURATION_MS);
|
||||
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 s.join('player-b', f.b.gameSessionId)).match!.matchId;
|
||||
await f.clock.advance(MATCH_COUNTDOWN_MS);
|
||||
await s.action('player-a', f.a.gameSessionId, id, 1, 'guard');
|
||||
// Jump to the deadline without executing timers: the request itself must settle it.
|
||||
f.clock.time += TURN_DURATION_MS;
|
||||
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);
|
||||
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, 'matched');
|
||||
} 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 s.join('player-b', 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);
|
||||
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 s.join('player-b', 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, 403); assert.equal(rejected.json().code, 'PROFILE_NAME_READONLY');
|
||||
}
|
||||
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 f.service.join('player-b', 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 s.join('player-b', 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(); }
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { createStatsStore } from '../src/server/stats-store.js';
|
||||
import { createAnalyticsStore } from '../src/server/analytics-store.js';
|
||||
import { getAnalyticsReport } from '../src/server/analytics-report.js';
|
||||
|
||||
test('dashboard preserves visit analytics and counts current client matches once across participants', { skip: !process.env.TEST_MONGODB_URI }, async () => {
|
||||
const store = await createStatsStore({ mongoUri: process.env.TEST_MONGODB_URI!, databaseName: `dragon_ball_test_report_${randomUUID().replaceAll('-', '')}` });
|
||||
try {
|
||||
const analytics = await createAnalyticsStore(store.db), a = await store.createGuest(), b = await store.createGuest();
|
||||
const now = new Date('2026-10-13T08:00:00Z');
|
||||
const meta = { device: 'mobile', browser: 'Chrome 141', source: 'social', invitation: false };
|
||||
const s1 = await analytics.touch(a.playerId, new Date('2026-10-11T14:00:00Z'), true, meta);
|
||||
const s2 = await analytics.touch(a.playerId, new Date('2026-10-12T15:00:00Z'), true, meta);
|
||||
const s3 = await analytics.touch(b.playerId, new Date('2026-10-12T15:00:00Z'), true, { ...meta, device: 'pc' });
|
||||
const base = { environment: 'production', schemaVersion: 1, appVersion: '0.1.0', source: 'client' as const };
|
||||
for (const [i, s] of [s1, s2, s3].entries()) await analytics.insert({ ...base, _id: `view-${i}`, playerId: s.playerId, sessionId: s.sessionId, occurredAt: s.startedAt, eventType: 'page_view' });
|
||||
for (const [i, s] of [s2, s3].entries()) {
|
||||
await analytics.insert({ ...base, _id: `start-${i}`, playerId: s.playerId, sessionId: s.sessionId, eventType: 'game_started', matchId: 'shared-match', mode: 'friend', occurredAt: new Date('2026-10-13T00:00:00Z') });
|
||||
await analytics.insert({ ...base, _id: `finish-${i}`, playerId: s.playerId, sessionId: s.sessionId, eventType: 'game_finished', matchId: 'shared-match', mode: 'friend', outcome: i ? 'loss' : 'win', occurredAt: new Date('2026-10-13T00:01:00Z') });
|
||||
}
|
||||
await analytics.insert({ ...base, _id: 'test-game', eventType: 'game_started', matchId: 'test', mode: 'bot', occurredAt: new Date('2026-10-13T00:00:00Z'), environment: 'test' });
|
||||
const filters = { start: new Date('2026-10-11T00:00:00Z'), end: now };
|
||||
const report = await getAnalyticsReport(store.db, filters, now);
|
||||
assert.equal(report.overview.visitors, 2); assert.equal(report.overview.wau.visitors, 2); assert.equal(report.overview.mau.visitors, 2);
|
||||
assert.equal(report.clientGameplay.players, 2); assert.equal(report.clientGameplay.startedMatches, 1); assert.equal(report.clientGameplay.resultReports, 2);
|
||||
assert.equal(report.daily.find(d => d.day === '2026-10-13')!.matches, 1);
|
||||
assert.equal(report.overview.dau.players, 2);
|
||||
assert.ok(!('gameplay' in report)); assert.ok(!('sessionConversion' in report.overview));
|
||||
const filtered = await getAnalyticsReport(store.db, { ...filters, device: 'mobile' }, now);
|
||||
assert.equal(filtered.overview.visitors, 1); assert.equal(filtered.clientGameplay.players, 1);
|
||||
assert.equal(filtered.revisit.visitCohorts[0].size, 1);
|
||||
const bot = await getAnalyticsReport(store.db, { ...filters, mode: 'bot' }, now);
|
||||
assert.equal(bot.overview.visitors, 2); assert.equal(bot.clientGameplay.startedMatches, 0);
|
||||
assert.equal((await store.db.listCollections({ name: 'matches' }).toArray()).length, 0);
|
||||
} finally { await store.db.dropDatabase(); await store.close(); }
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { randomBytes, randomUUID } from 'node:crypto';
|
||||
import { createStatsStore } from '../src/server/stats-store.js';
|
||||
import { createGameSessionStore } from '../src/server/game-session-store.js';
|
||||
import { createResultReporter } from '../src/client/result-reporter.js';
|
||||
import { Analytics } from '../src/server/analytics.js';
|
||||
import { createAnalyticsStore } from '../src/server/analytics-store.js';
|
||||
import { ManualClock } from './helpers.js';
|
||||
import { RETRY_MAX_AGE_MS, RETRY_QUEUE_LIMIT } from '../constants/analytics.js';
|
||||
const uri = process.env.TEST_MONGODB_URI;
|
||||
test('lost result response keeps registration locked until idempotent retry settles', { skip: !uri }, async () => {
|
||||
const store = await createStatsStore({ mongoUri: uri!, databaseName: `dragon_ball_test_retry_${randomUUID().replaceAll('-', '')}` });
|
||||
const clock = new ManualClock(), sessions = await createGameSessionStore(store.db, clock.now);
|
||||
const auth = await sessions.create(randomBytes(32).toString('base64url'));
|
||||
const active = await sessions.register(auth, { requestId: randomUUID(), matchId: randomUUID(), mode: 'bot', expectedPreviousSeq: 0 });
|
||||
const values = new Map<string, string>(); let requests = 0;
|
||||
const reporter = createResultReporter({ gameSessionId: auth.gameSessionId, record: async data => {
|
||||
if (++requests === 1) throw new Error('offline');
|
||||
const ack = await sessions.record(auth, data);
|
||||
if (requests === 2) throw new Error('response lost');
|
||||
return ack;
|
||||
} }, { getItem: k => values.get(k) ?? null, setItem: (k, v) => { values.set(k, v); }, removeItem: k => { values.delete(k); } }, clock);
|
||||
try {
|
||||
const pending = reporter.submit({ ...active, outcome: 'win', reason: 'hit' });
|
||||
await assert.rejects(sessions.register(auth, { requestId: randomUUID(), matchId: randomUUID(), mode: 'bot', expectedPreviousSeq: 1 }), /MATCH_PENDING/);
|
||||
await clock.advance(10000); const ack = await pending;
|
||||
assert.equal(ack.status, 'duplicate'); assert.equal(requests, 3); assert.equal(values.size, 0);
|
||||
assert.equal((await sessions.get(auth)).stats.bot.current, 1);
|
||||
assert.equal((await sessions.get(auth)).stats.bot.wins, 1);
|
||||
} finally { reporter.destroy(); await store.db.dropDatabase(); await store.close(); }
|
||||
});
|
||||
test('analytics outage is bounded, retry preserves receive time and game result persistence continues', { skip: !uri }, async () => {
|
||||
const databaseName = `dragon_ball_test_outage_${randomUUID().replaceAll('-', '')}`;
|
||||
const store = await createStatsStore({ mongoUri: uri!, databaseName }); const clock = new ManualClock();
|
||||
const aStore = await createAnalyticsStore(store.db); const original = aStore.insert.bind(aStore); let offline = true;
|
||||
const analytics = new Analytics({ ...aStore, insert: e => offline ? Promise.reject(new Error('offline')) : original(e) }, clock);
|
||||
const guest = await store.createGuest(), player = { playerId: guest.playerId };
|
||||
const sessions = await createGameSessionStore(store.db, clock.now);
|
||||
const auth = await sessions.create(randomBytes(32).toString('base64url'), guest.playerId);
|
||||
try {
|
||||
const at = clock.now(); await assert.rejects(analytics.clientEvent(player, { eventId: 'page-1', eventType: 'page_view', screen: 'home' }));
|
||||
const active = await sessions.register(auth, { requestId: randomUUID(), matchId: randomUUID(), mode: 'bot', expectedPreviousSeq: 0 });
|
||||
await sessions.record(auth, { ...active, outcome: 'loss', reason: 'timeout' });
|
||||
assert.equal((await sessions.get(auth)).stats.bot.losses, 1);
|
||||
assert.ok(analytics.status().incomplete); assert.equal(analytics.status().failures, 1);
|
||||
offline = false; await clock.advance(5000);
|
||||
const event = await store.db.collection('analytics_events').findOne(); assert.equal(+event!.occurredAt, at);
|
||||
assert.equal(await store.db.collection('analytics_events').countDocuments(), 1);
|
||||
assert.equal(analytics.status().queued, 0);
|
||||
offline = true;
|
||||
await assert.rejects(analytics.clientEvent(player, { eventId: 'page-2', eventType: 'page_view', screen: 'home' }));
|
||||
const deferred: Promise<unknown>[] = [];
|
||||
for (let i = 1; i < RETRY_QUEUE_LIMIT; i++) deferred.push(analytics.enqueue(`pending-${i}`, clock.now(), async () => {}).catch(() => {}));
|
||||
await assert.rejects(analytics.enqueue('overflow', clock.now(), async () => {}));
|
||||
assert.equal(analytics.status().queued, 100); assert.ok(analytics.status().dropped > 0);
|
||||
const retry = clock.timers.get(analytics.timer)!; clock.timers.delete(analytics.timer);
|
||||
clock.time += RETRY_MAX_AGE_MS + 1; retry.callback();
|
||||
await Promise.all(deferred);
|
||||
assert.equal(analytics.status().queued, 0); assert.ok(analytics.status().dropped >= 101);
|
||||
analytics.windows.set(player.playerId, { at: clock.now(), count: 120 });
|
||||
await assert.rejects(analytics.clientEvent(player, { eventId: 'limited', eventType: 'page_view', screen: 'home' }));
|
||||
assert.equal(analytics.status().rateLimited, 1); assert.equal(analytics.status().incomplete, true);
|
||||
} finally { await analytics.close(); await store.db.dropDatabase(); await store.close(); }
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createResultReporter } from '../src/client/result-reporter.js';
|
||||
import { emptyPlayerStats, type ResultSubmission } from '../src/shared/p2p-types.js';
|
||||
import { PENDING_RESULT_KEY } from '../constants/recording.js';
|
||||
|
||||
function storage() {
|
||||
const data = new Map<string, string>();
|
||||
return { getItem: (key: string) => data.get(key) ?? null, setItem: (key: string, value: string) => { data.set(key, value); }, removeItem: (key: string) => { data.delete(key); } };
|
||||
}
|
||||
const result: ResultSubmission = { matchId: 'm1', seq: 1, mode: 'friend', outcome: 'win', reason: 'hit' };
|
||||
test('lost result response retries identical payload and clears persisted result only after acknowledgement', async () => {
|
||||
const store = storage(), sent: ResultSubmission[] = []; const timers: { fn: () => void; ms: number }[] = [];
|
||||
const reporter = createResultReporter({ gameSessionId: 's1', record: async data => {
|
||||
sent.push(structuredClone(data)); if (sent.length === 1) throw new Error('network');
|
||||
return { status: 'duplicate', stats: emptyPlayerStats() };
|
||||
} }, store, { setTimeout: (fn, ms) => { timers.push({ fn, ms }); return timers.length; }, clearTimeout: () => {} });
|
||||
const promise = reporter.submit(result);
|
||||
await new Promise(r => setImmediate(r));
|
||||
assert.ok(store.getItem(PENDING_RESULT_KEY)); assert.equal(timers[0].ms, 2000);
|
||||
timers.shift()!.fn();
|
||||
assert.equal((await promise).status, 'duplicate');
|
||||
assert.deepEqual(sent, [result, result]); assert.equal(store.getItem(PENDING_RESULT_KEY), null);
|
||||
reporter.destroy();
|
||||
});
|
||||
test('reload resumes the pending result but a different session never submits it', async () => {
|
||||
const store = storage(); store.setItem(PENDING_RESULT_KEY, JSON.stringify({ gameSessionId: 's1', result }));
|
||||
const sent: ResultSubmission[] = [];
|
||||
const wrong = createResultReporter({ gameSessionId: 's2', record: async data => { sent.push(data); return { status: 'applied', stats: emptyPlayerStats() }; } }, store);
|
||||
await assert.rejects(wrong.resumePending(), /SESSION_MISMATCH/); assert.equal(sent.length, 0); wrong.destroy();
|
||||
const correct = createResultReporter({ gameSessionId: 's1', record: async data => { sent.push(data); return { status: 'applied', stats: emptyPlayerStats() }; } }, store);
|
||||
assert.equal((await correct.resumePending())?.status, 'applied'); assert.deepEqual(sent, [result]); correct.destroy();
|
||||
});
|
||||
test('permanent rejection stops retries and a different result cannot replace an in-flight one', async () => {
|
||||
const store = storage(); let count = 0;
|
||||
const reporter = createResultReporter({ gameSessionId: 's1', record: async () => { count++; throw Object.assign(new Error('INVALID_SESSION'), { status: 401 }); } }, store);
|
||||
const first = reporter.submit(result);
|
||||
await assert.rejects(reporter.submit({ ...result, matchId: 'm2' }), /RESULT_PENDING/);
|
||||
await assert.rejects(first, /INVALID_SESSION/); assert.equal(count, 1); assert.ok(store.getItem(PENDING_RESULT_KEY)); reporter.destroy();
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { MongoClient } from 'mongodb';
|
||||
import { createRoomStore } from '../src/server/rooms.js';
|
||||
import { WAITING_ROOM_TTL_MS } from '../constants/session.js';
|
||||
|
||||
test('DB rooms atomically reserve the last seat, expire before TTL deletion and rematch once', { skip: !process.env.TEST_MONGODB_URI }, async () => {
|
||||
const client = await new MongoClient(process.env.TEST_MONGODB_URI!, { serverSelectionTimeoutMS: 5000 }).connect();
|
||||
const db = client.db(`dragon_ball_test_rooms_${randomUUID().replaceAll('-', '')}`);
|
||||
try {
|
||||
let now = Date.now(); const rooms = await createRoomStore(db, () => now);
|
||||
const request = randomUUID(); const room = await rooms.create('host', request);
|
||||
assert.deepEqual(await rooms.create('host', request), room);
|
||||
await assert.rejects(rooms.join('host', room.code), /SELF_JOIN/);
|
||||
const entries = await Promise.allSettled(['guestA', 'guestB'].map(id => rooms.join(id, room.code)));
|
||||
assert.equal(entries.filter(r => r.status === 'fulfilled').length, 1);
|
||||
const guest = entries[0].status === 'fulfilled' ? 'guestA' : 'guestB';
|
||||
const paired = await rooms.get(room.id, 'host'); assert.equal(paired.count, 2);
|
||||
assert.equal((await rooms.join(guest, room.code)).currentMatchId, paired.currentMatchId);
|
||||
await assert.rejects(rooms.get(room.id, 'stranger'), /ROOM_NOT_FOUND/);
|
||||
const next = await Promise.all([rooms.rematch(room.id, 'host', 1), rooms.rematch(room.id, guest, 1), rooms.rematch(room.id, guest, 1)]);
|
||||
const current = await rooms.get(room.id, 'host');
|
||||
assert.equal(current.round, 2); assert.notEqual(current.currentMatchId, room.currentMatchId);
|
||||
for (const r of next.filter(r => r.round === 2)) assert.equal(r.currentMatchId, current.currentMatchId);
|
||||
assert.equal((await rooms.rematch(room.id, 'host', 1)).currentMatchId, current.currentMatchId);
|
||||
await rooms.leave(room.id, guest); assert.equal((await rooms.get(room.id, 'host')).status, 'closed');
|
||||
const waiting = await rooms.create('host', randomUUID()); now += WAITING_ROOM_TTL_MS;
|
||||
assert.ok(await db.collection<any>('rooms').findOne({ _id: waiting.id }));
|
||||
await assert.rejects(rooms.join('new', waiting.code), /ROOM_EXPIRED/);
|
||||
const fresh = await rooms.create('host', randomUUID()); assert.notEqual(fresh.id, waiting.id);
|
||||
} finally { try { await db.dropDatabase(); } finally { await client.close(); } }
|
||||
});
|
||||
test('one session cannot occupy two open rooms even with concurrent creates', { skip: !process.env.TEST_MONGODB_URI }, async () => {
|
||||
const client = await new MongoClient(process.env.TEST_MONGODB_URI!, { serverSelectionTimeoutMS: 5000 }).connect();
|
||||
const db = client.db(`dragon_ball_test_roomrace_${randomUUID().replaceAll('-', '')}`);
|
||||
try {
|
||||
const rooms = await createRoomStore(db);
|
||||
const results = await Promise.allSettled([rooms.create('host', randomUUID()), rooms.create('host', randomUUID())]);
|
||||
assert.equal(results.filter(r => r.status === 'fulfilled').length, 1);
|
||||
} finally { try { await db.dropDatabase(); } finally { await client.close(); } }
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { isLegalAction, resolveTurn } from '../src/shared/rules.js';
|
||||
|
||||
const actions = ['charge', 'blast', 'guard', 'beam', 'dodge'] as const;
|
||||
const winners = [
|
||||
[null, 1, null, 1, null],
|
||||
[0, null, null, 1, null],
|
||||
[null, null, null, 1, null],
|
||||
[0, 0, 0, null, null],
|
||||
[null, null, null, null, null],
|
||||
];
|
||||
for (let a = 0; a < 5; a++) for (let b = 0; b < 5; b++) {
|
||||
test(`${actions[a]} / ${actions[b]} follows the agreed outcome table`, () => {
|
||||
const result = resolveTurn([3, 3], [actions[a], actions[b]]) as { winnerIndex: number | null };
|
||||
assert.equal(result.winnerIndex, winners[a][b]);
|
||||
});
|
||||
}
|
||||
test('first turn allows only charge and guard; unknown actions are rejected', () => {
|
||||
assert.equal(isLegalAction('charge', 0), true);
|
||||
assert.equal(isLegalAction('guard', 0), true);
|
||||
for (const action of ['blast', 'beam', 'dodge', 'fake']) assert.equal(isLegalAction(action, 0), false);
|
||||
});
|
||||
test('energy is spent on evasion and clashing attacks, and a hit cancels charging', () => {
|
||||
assert.deepEqual(resolveTurn([3, 1], ['beam', 'dodge']), { energy: [0, 0], winnerIndex: null, hitIndex: null });
|
||||
assert.deepEqual(resolveTurn([1, 0], ['blast', 'charge']), { energy: [0, 0], winnerIndex: 0, hitIndex: 1 });
|
||||
assert.deepEqual(resolveTurn([1, 1], ['blast', 'blast']), { energy: [0, 0], winnerIndex: null, hitIndex: null });
|
||||
assert.deepEqual(resolveTurn([0, 0], ['charge', 'charge']), { energy: [1, 1], winnerIndex: null, hitIndex: null });
|
||||
assert.deepEqual(resolveTurn([0, 1], ['guard', 'dodge']), { energy: [0, 0], winnerIndex: null, hitIndex: null });
|
||||
});
|
||||
test('illegal actions cannot spend energy or mutate the input', () => {
|
||||
const energy: [number, number] = [0, 0];
|
||||
assert.throws(() => resolveTurn(energy, ['blast', 'charge']));
|
||||
assert.deepEqual(energy, [0, 0]);
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { randomBytes, randomUUID } from 'node:crypto';
|
||||
import { MongoClient } from 'mongodb';
|
||||
import { io } from 'socket.io-client';
|
||||
import { createGameServer } from '../src/server/create-server.js';
|
||||
import { ManualClock } from './helpers.js';
|
||||
const uri = process.env.TEST_MONGODB_URI;
|
||||
|
||||
test('HTTP game registration survives server restart without game timers and old gameplay sockets are rejected', { skip: !uri }, async () => {
|
||||
const databaseName = 'dragon_ball_test_server_' + randomUUID().replaceAll('-', ''), clock = new ManualClock();
|
||||
const options = { mongoUri: uri!, databaseName, clientDirectory: 'dist/client', clock };
|
||||
let app = await createGameServer(options);
|
||||
const client = await new MongoClient(uri!).connect();
|
||||
let socket: ReturnType<typeof io> | undefined;
|
||||
try {
|
||||
const url = await app.listen({ host: '127.0.0.1', port: 0 });
|
||||
assert.equal((await app.inject('/health')).statusCode, 200);
|
||||
const session = (await app.inject({ method: 'POST', url: '/api/game-sessions', payload: { createRequestId: randomBytes(32).toString('base64url') } })).json().data;
|
||||
const headers = { authorization: 'Bearer ' + session.token, 'x-game-session': session.gameSessionId };
|
||||
const input = { requestId: 'register', matchId: 'bot-match', mode: 'bot', expectedPreviousSeq: 0 };
|
||||
const match = (await app.inject({ method: 'POST', url: '/api/game-session/matches', headers, payload: input })).json().data;
|
||||
assert.equal(match.seq, 1);
|
||||
await clock.advance(12000);
|
||||
assert.equal((await app.inject({ url: '/api/game-session', headers })).json().data.activeMatch.matchId, 'bot-match');
|
||||
assert.equal(await client.db(databaseName).collection('matches').countDocuments(), 0);
|
||||
socket = io(url, { autoConnect: false, reconnection: false, transports: ['websocket'] });
|
||||
const status = new Promise<string>(resolve => { socket!.once('connect_error', () => resolve('rejected')); socket!.once('connect', () => resolve('accepted')); });
|
||||
socket.connect(); assert.equal(await status, 'rejected'); socket.disconnect();
|
||||
await app.close(); app = await createGameServer(options);
|
||||
assert.equal((await app.inject({ url: '/api/game-session', headers })).json().data.activeMatch.matchId, 'bot-match');
|
||||
const payload = { ...match, outcome: 'loss', reason: 'timeout' };
|
||||
assert.equal((await app.inject({ method: 'POST', url: '/api/game-session/results', headers, payload })).json().data.stats.bot.losses, 1);
|
||||
assert.equal((await app.inject({ method: 'POST', url: '/api/game-session/results', headers, payload })).json().data.status, 'duplicate');
|
||||
assert.equal((await app.inject({ method: 'POST', url: '/api/game-session/results', payload })).statusCode, 401);
|
||||
const state = (await app.inject({ url: '/api/game-session', headers })).json().data;
|
||||
assert.equal(state.activeMatch, undefined); assert.equal(state.stats.bot.losses, 1);
|
||||
} finally { socket?.disconnect(); await app.close(); try { await client.db(databaseName).dropDatabase(); } finally { await client.close(); } }
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { randomBytes, randomUUID, createHmac } from 'node:crypto';
|
||||
import { createServer } from 'node:http';
|
||||
import { Server } from 'socket.io';
|
||||
import { io as connect, type Socket } from 'socket.io-client';
|
||||
import { MongoClient } from 'mongodb';
|
||||
import { createGameSessionStore } from '../src/server/game-session-store.js';
|
||||
import { createRoomStore } from '../src/server/rooms.js';
|
||||
import { registerSignaling } from '../src/server/signaling.js';
|
||||
|
||||
test('signaling authenticates membership, relays only within room and releases sockets without forfeiting', { skip: !process.env.TEST_MONGODB_URI }, async () => {
|
||||
const client = await new MongoClient(process.env.TEST_MONGODB_URI!, { serverSelectionTimeoutMS: 5000 }).connect();
|
||||
const db = client.db(`dragon_ball_test_signal_${randomUUID().replaceAll('-', '')}`);
|
||||
const http = createServer(), io = new Server(http); const sockets: Socket[] = [];
|
||||
try {
|
||||
const sessions = await createGameSessionStore(db), rooms = await createRoomStore(db);
|
||||
const a = await sessions.create(randomBytes(32).toString('base64url')), b = await sessions.create(randomBytes(32).toString('base64url')), outsider = await sessions.create(randomBytes(32).toString('base64url'));
|
||||
const room = await rooms.create(a.gameSessionId, randomUUID()); await rooms.join(b.gameSessionId, room.code);
|
||||
const signaling = registerSignaling(io, sessions, rooms, { turnUrls: ['turn:localhost:3478'], turnSecret: 'test-secret' });
|
||||
await new Promise<void>(r => http.listen(0, '127.0.0.1', r));
|
||||
const url = `http://127.0.0.1:${(http.address() as any).port}/signal`;
|
||||
function socket(auth: typeof a) { const s = connect(url, { transports: ['websocket'], reconnection: false, autoConnect: false, auth: { ...auth, roomId: room.id } }); sockets.push(s); return s; }
|
||||
const sa = socket(a), sb = socket(b), stranger = socket(outsider);
|
||||
const ready = (s: Socket) => new Promise<void>((r, reject) => { s.once('connect', () => r()); s.once('connect_error', reject); s.connect(); });
|
||||
await Promise.all([ready(sa), ready(sb)]); await assert.rejects(ready(stranger));
|
||||
const received = new Promise<any>(r => sb.once('signal', r));
|
||||
const packet = { type: 'offer', matchId: room.currentMatchId, epoch: 0, payload: { type: 'offer', sdp: 'test-sdp' } };
|
||||
assert.equal((await sa.timeout(2000).emitWithAck('signal', packet)).ok, true);
|
||||
assert.equal((await received).payload.sdp, 'test-sdp');
|
||||
assert.equal((await sa.timeout(2000).emitWithAck('signal', { ...packet, matchId: randomUUID() })).ok, false);
|
||||
assert.equal((await sb.timeout(2000).emitWithAck('signal', packet)).ok, false);
|
||||
const resumed = socket(a); await ready(resumed); await new Promise(r => setTimeout(r, 20));
|
||||
assert.equal(sa.connected, false); assert.equal(resumed.connected, true);
|
||||
assert.equal(signaling.namespace.sockets.size, 2);
|
||||
const resumedMessage = new Promise<any>(r => sb.once('signal', r));
|
||||
assert.equal((await resumed.timeout(2000).emitWithAck('signal', { ...packet, epoch: 1 })).ok, true);
|
||||
assert.equal((await resumedMessage).epoch, 1);
|
||||
const active = await sessions.register(a, { requestId: randomUUID(), matchId: room.currentMatchId, roomId: room.id, mode: 'friend', expectedPreviousSeq: 0 });
|
||||
resumed.disconnect(); sb.disconnect(); await new Promise(r => setTimeout(r, 20));
|
||||
assert.equal(signaling.namespace.sockets.size, 0);
|
||||
assert.equal((await sessions.get(a)).activeMatch?.matchId, active.matchId);
|
||||
const rtc = await signaling.rtcConfig(a, room.id);
|
||||
const turn = rtc.iceServers[0]; assert.ok(turn.username);
|
||||
assert.equal(turn.credential, createHmac('sha1', 'test-secret').update(turn.username!).digest('base64'));
|
||||
assert.ok(rtc.expiresAt > Date.now());
|
||||
} finally {
|
||||
sockets.forEach(s => s.disconnect()); await new Promise<void>(r => io.close(() => r()));
|
||||
try { await db.dropDatabase(); } finally { await client.close(); }
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { createStatsStore } from '../src/server/stats-store.js';
|
||||
|
||||
test('visitor identity persists without creating the removed match collection', { skip: !process.env.TEST_MONGODB_URI }, async () => {
|
||||
const options = { mongoUri: process.env.TEST_MONGODB_URI!, databaseName: `dragon_ball_test_stats_${randomUUID().replaceAll('-', '')}` };
|
||||
let store = await createStatsStore(options);
|
||||
try {
|
||||
const guest = await store.createGuest('a'.repeat(43));
|
||||
assert.deepEqual(await store.createGuest('a'.repeat(43)), guest);
|
||||
const player = await store.db.collection('players').findOne({ _id: guest.playerId as any });
|
||||
assert.ok(player?.guestTokenHash);
|
||||
assert.ok(!JSON.stringify(player).includes(guest.guestToken));
|
||||
assert.equal(await store.findGuest('bad-token'), null);
|
||||
await store.close(); store = await createStatsStore(options);
|
||||
assert.deepEqual(await store.findGuest(guest.guestToken), guest);
|
||||
assert.equal((await store.db.listCollections({ name: 'matches' }).toArray()).length, 0);
|
||||
} finally { await store.db.dropDatabase(); await store.close(); }
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { LocalMatchState } from '../src/shared/p2p-types.js';
|
||||
// Browser-only visual fixture. Uses the real view, rules and timings; never shipped.
|
||||
import { createMatchView } from '../src/client/match-view.js';
|
||||
import { resolveTurn } from '../src/shared/rules.js';
|
||||
import { MATCH_COUNTDOWN_MS, TURN_DURATION_MS, REVEAL_DURATION_MS } from '../constants/gameplay.js';
|
||||
import type { ActionId, Pair, PlayerStats } from '../src/shared/types.js';
|
||||
|
||||
const container = document.querySelector<HTMLElement>('#match')!;
|
||||
const zero = { current: 0, best: 0, wins: 0, losses: 0, draws: 0 };
|
||||
const stats: PlayerStats = { bot: { ...zero }, friend: { ...zero } };
|
||||
let current: LocalMatchState, timer: ReturnType<typeof setTimeout> | undefined;
|
||||
let selfIndex: 0 | 1 = 0;
|
||||
const view = createMatchView(container, async action => {
|
||||
current = { ...current, ownAction: action, version: current.version + 1, clockNow: Date.now() };
|
||||
view.update(current);
|
||||
return true;
|
||||
}, () => prepare(), () => prepare(), () => { document.body.dataset.screen = 'result'; });
|
||||
function state(): LocalMatchState {
|
||||
const now = Date.now();
|
||||
return { matchId: crypto.randomUUID(), mode: 'friend', phase: 'selecting', version: 1, selfIndex, energy: [3, 3], turnId: 1, clockNow: now, startsAt: now, deadline: now + TURN_DURATION_MS, ownAction: null, rematch: [false, false], opponentPresent: true, opponentLeft: false, saveStatus: 'saved', aborted: false };
|
||||
}
|
||||
function show(s: LocalMatchState) { document.body.dataset.screen = 'match'; current = s; view.update(s); }
|
||||
function prepare() {
|
||||
clearTimeout(timer);
|
||||
const s = state(); s.phase = 'countdown'; s.startsAt += MATCH_COUNTDOWN_MS; s.deadline = s.startsAt + TURN_DURATION_MS;
|
||||
show(s);
|
||||
timer = setTimeout(() => show({ ...current, phase: 'selecting', clockNow: Date.now(), version: current.version + 1 }), MATCH_COUNTDOWN_MS);
|
||||
}
|
||||
function reveal(actions: Pair<ActionId>, energy: Pair<number> = [3, 3], elapsed = 0) {
|
||||
clearTimeout(timer);
|
||||
const s = state(), now = Date.now() - elapsed, outcome = resolveTurn(energy, actions);
|
||||
s.energy = outcome.energy; s.phase = outcome.winnerIndex === null ? 'revealing' : 'finished';
|
||||
s.reveal = { ...outcome, matchId: s.matchId, turnId: 1, actions, energyBefore: energy, revealedAt: now, revealEndsAt: now + REVEAL_DURATION_MS };
|
||||
if (outcome.winnerIndex !== null) s.finished = { matchId: s.matchId, winnerIndex: outcome.winnerIndex, reason: 'hit', stats, revealEndsAt: now + REVEAL_DURATION_MS, saved: true };
|
||||
show(s);
|
||||
if (!s.finished) timer = setTimeout(() => show({ ...current, phase: 'selecting', reveal: undefined, turnId: 2, ownAction: null, clockNow: Date.now(), deadline: Date.now() + TURN_DURATION_MS, version: current.version + 1 }), REVEAL_DURATION_MS);
|
||||
}
|
||||
function result(winnerIndex: 0 | 1 | null) {
|
||||
clearTimeout(timer);
|
||||
const s = state(); s.phase = 'finished';
|
||||
s.finished = { matchId: s.matchId, winnerIndex, reason: winnerIndex === null ? 'double_timeout' : 'timeout', stats, revealEndsAt: Date.now(), saved: true };
|
||||
show(s);
|
||||
}
|
||||
const scenarios: [string, () => void][] = [
|
||||
['준비', prepare], ['선택', () => { clearTimeout(timer); show(state()); }],
|
||||
['충전', () => reveal(['charge', 'charge'])], ['파 명중', () => reveal(['blast', 'charge'])],
|
||||
['방어', () => reveal(['guard', 'blast'])], ['상쇄', () => reveal(['blast', 'blast'])],
|
||||
['순간이동', () => reveal(['dodge', 'beam'])], ['에네르기파', () => reveal(['beam', 'guard'])],
|
||||
['왼쪽 피격', () => reveal(['charge', 'blast'])], ['왼쪽 에네르기파 피격', () => reveal(['guard', 'beam'])],
|
||||
['에네르기파 상쇄', () => reveal(['beam', 'beam'])], ['파 회피', () => reveal(['blast', 'dodge'])],
|
||||
['양쪽 막기', () => reveal(['guard', 'guard'])],
|
||||
['피니시 복구', () => reveal(['blast', 'charge'], [3, 3], REVEAL_DURATION_MS + 500)],
|
||||
['컷씬 중간 복구', () => reveal(['beam', 'guard'], [3, 3], 650)],
|
||||
['에네르기파 표정 복구', () => reveal(['beam', 'guard'], [3, 3], 2500)],
|
||||
['에네르기파 소멸 복구', () => reveal(['beam', 'guard'], [3, 3], 3100)],
|
||||
['에네르기파 종료 뒤 복구', () => reveal(['guard', 'beam'], [3, 3], 60_000)],
|
||||
['컷씬 종료 뒤 복구', () => reveal(['charge', 'blast'], [3, 3], 60_000)],
|
||||
['컷씬 저장 응답', () => {
|
||||
reveal(['blast', 'charge']);
|
||||
timer = setTimeout(() => {
|
||||
show({ ...current, version: current.version + 1, clockNow: Date.now() });
|
||||
timer = setTimeout(() => show({ ...current, version: current.version + 1, clockNow: Date.now() }), 4300);
|
||||
}, 700);
|
||||
}],
|
||||
['컷씬 중 새 대전', () => {
|
||||
reveal(['blast', 'charge']);
|
||||
timer = setTimeout(prepare, 900);
|
||||
}],
|
||||
['패배', () => result(selfIndex === 0 ? 1 : 0)], ['승리', () => result(selfIndex)], ['무승부', () => result(null)],
|
||||
['기 부족', () => { clearTimeout(timer); show({ ...state(), energy: [0, 0] }); }],
|
||||
['오른쪽 플레이어', () => { selfIndex = selfIndex === 0 ? 1 : 0; clearTimeout(timer); show(state()); }],
|
||||
];
|
||||
const picker = document.createElement('select'); picker.setAttribute('aria-label', '검증 상태');
|
||||
for (const [label] of scenarios) { const option = document.createElement('option'); option.textContent = label; option.value = label; picker.append(option); }
|
||||
const replay = document.createElement('button'); replay.textContent = '재생';
|
||||
const run = () => scenarios.find(([label]) => label === picker.value)![1]();
|
||||
picker.onchange = run; replay.onclick = run;
|
||||
document.querySelector('#scenarios')!.append(picker, replay);
|
||||
prepare();
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
// Isolated load fixture; never imported by the production server.
|
||||
import { Collection } from 'mongodb';
|
||||
import { createGameServer } from '../src/server/create-server.js';
|
||||
const databaseName = process.env.LOAD_DATABASE!;
|
||||
if (!/^dragon_ball_test_load_[a-f0-9]+$/.test(databaseName)) throw new Error('TEST_DATABASE_REQUIRED');
|
||||
const writes: Record<string, number> = {};
|
||||
for (const name of ['insertOne', 'updateOne', 'updateMany', 'findOneAndUpdate'] as const) {
|
||||
const original = Collection.prototype[name] as Function;
|
||||
(Collection.prototype as any)[name] = function (...args: unknown[]) {
|
||||
const key = `${this.collectionName}.${name}`; writes[key] = (writes[key] ?? 0) + 1;
|
||||
return original.apply(this, args);
|
||||
};
|
||||
}
|
||||
const app = await createGameServer({ mongoUri: process.env.TEST_MONGODB_URI!, databaseName, clientDirectory: '.superpowers/missing-load-client', environment: 'test' });
|
||||
const sockets = new Set<unknown>(); let peakSockets = 0;
|
||||
app.server.on('upgrade', (_request, socket) => { sockets.add(socket); peakSockets = Math.max(peakSockets, sockets.size); socket.once('close', () => sockets.delete(socket)); });
|
||||
let previous = process.cpuUsage(), at = performance.now();
|
||||
const metrics = setInterval(() => {
|
||||
const next = performance.now(), usage = process.cpuUsage(), delta = next - at;
|
||||
process.send?.({ type: 'metrics', rss: process.memoryUsage().rss, cpuPercent: ((usage.user - previous.user) + (usage.system - previous.system)) / (delta * 1000) * 100,
|
||||
sockets: sockets.size, peakSockets, writes });
|
||||
previous = usage; at = next;
|
||||
}, 1000);
|
||||
process.send?.({ type: 'ready', url: await app.listen({ port: 0, host: '127.0.0.1' }) });
|
||||
process.on('message', message => { if (message === 'stop') void (async () => { clearInterval(metrics); await app.close(); process.exit(0); })(); });
|
||||
@@ -0,0 +1,79 @@
|
||||
// HTTP/DB and transient signaling only: this does NOT simulate real RTC pairs.
|
||||
import { fork } from 'node:child_process';
|
||||
import { randomBytes, randomUUID } from 'node:crypto';
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import { MongoClient } from 'mongodb';
|
||||
import { io } from 'socket.io-client';
|
||||
const pairCount = 1500, seconds = Number(process.env.LOAD_SECONDS ?? 900);
|
||||
const databaseName = `dragon_ball_test_load_${randomUUID().replaceAll('-', '')}`;
|
||||
const child = fork(new URL('./webrtc-load-server.ts', import.meta.url), { env: { ...process.env, LOAD_DATABASE: databaseName }, stdio: ['ignore', 'inherit', 'inherit', 'ipc'], windowsHide: true });
|
||||
let url = '', phase = 'ramp', signalingConnections = 0, failures = 0;
|
||||
const metrics: any[] = [], requests: Record<string, { ms: number[]; status: Record<string, number> }> = {};
|
||||
child.on('message', (message: any) => { if (message.type === 'metrics') metrics.push({ ...message, phase, at: Date.now() }); });
|
||||
await new Promise<void>((resolve, reject) => { child.on('message', (m: any) => { if (m.type === 'ready') { url = m.url; resolve(); } }); child.once('error', reject); child.once('exit', code => reject(new Error(`SERVER_EXIT_${code}`))); });
|
||||
const sleep = (ms: number) => new Promise(r => setTimeout(r, Math.max(0, ms)));
|
||||
async function post(path: string, data: unknown, session?: any) {
|
||||
const start = performance.now(); let status = 0;
|
||||
try {
|
||||
const response = await fetch(url + path, { method: 'POST', headers: { 'content-type': 'application/json', ...(session ? { authorization: `Bearer ${session.token}`, 'x-game-session': session.gameSessionId } : {}) }, body: JSON.stringify(data), signal: AbortSignal.timeout(10000) });
|
||||
status = response.status; const body: any = await response.json();
|
||||
if (!response.ok || !body.ok) throw new Error(`HTTP_${status}_${body.code}`);
|
||||
return body.data;
|
||||
} finally {
|
||||
const key = phase + ':' + path.replace(/\/rooms\/[^/]+\//, '/rooms/:id/');
|
||||
const entry = requests[key] ??= { ms: [], status: {} }; entry.ms.push(performance.now() - start); entry.status[status] = (entry.status[status] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
async function signal(session: any, room: any) {
|
||||
const socket = io(url + '/signal', { transports: ['websocket'], forceNew: true, reconnection: false, auth: { gameSessionId: session.gameSessionId, token: session.token, roomId: room.id } });
|
||||
try { await new Promise<void>((resolve, reject) => { socket.once('connect', () => resolve()); socket.once('connect_error', reject); }); signalingConnections++; }
|
||||
finally { socket.disconnect(); }
|
||||
}
|
||||
const pairs: { sessions: any[]; room: any; active: any[]; seq: number }[] = [];
|
||||
async function prepare(index: number) {
|
||||
const sessions = await Promise.all([0, 1].map(() => post('/api/game-sessions', { createRequestId: randomBytes(32).toString('base64url') })));
|
||||
let room = await post('/api/rooms', { requestId: randomUUID() }, sessions[0]);
|
||||
room = await post('/api/rooms/join', { code: room.code }, sessions[1]);
|
||||
await Promise.all(sessions.map(s => signal(s, room)));
|
||||
const active = await Promise.all(sessions.map(s => post('/api/game-session/matches', { requestId: randomUUID(), matchId: room.currentMatchId, roomId: room.id, mode: 'friend', expectedPreviousSeq: 0 }, s)));
|
||||
pairs[index] = { sessions, room, active, seq: 0 };
|
||||
}
|
||||
const inflight = new Set<Promise<void>>();
|
||||
function launch(work: Promise<void>) { const tracked = work.catch(error => { failures++; console.error(String(error)); }).finally(() => inflight.delete(tracked)); inflight.add(tracked); }
|
||||
const started = Date.now();
|
||||
try {
|
||||
for (let i = 0; i < pairCount; i++) { await sleep(started + i * 40 - Date.now()); launch(prepare(i)); }
|
||||
await Promise.all(inflight); if (pairs.filter(Boolean).length !== pairCount) throw new Error('RAMP_FAILED');
|
||||
const rampMs = Date.now() - started; console.log(JSON.stringify({ phase: 'ramp-complete', sessions: pairCount * 2, signalingConnections, rampMs }));
|
||||
phase = 'steady'; const steadyStart = Date.now(); let duplicateResults = 0;
|
||||
async function cycle(index: number) {
|
||||
const p = pairs[index % pairCount];
|
||||
const results = p.active.map((active, i) => ({ ...active, outcome: i === 0 ? 'win' : 'loss', reason: 'hit' }));
|
||||
await Promise.all(p.sessions.flatMap((s, i) => [post('/api/game-session/results', results[i], s), post('/api/analytics/activity', { visible: true }, s)]));
|
||||
if (index % 10 === 0) { await Promise.all(p.sessions.map((s, i) => post('/api/game-session/results', results[i], s))); duplicateResults += 2; }
|
||||
const round = p.room.round;
|
||||
const rooms = await Promise.all(p.sessions.map(s => post('/api/rooms/' + p.room.id + '/rematch', { round, requestId: randomUUID() }, s)));
|
||||
p.room = rooms.sort((a, b) => b.round - a.round)[0]; p.seq++;
|
||||
p.active = await Promise.all(p.sessions.map(s => post('/api/game-session/matches', { requestId: randomUUID(), matchId: p.room.currentMatchId, roomId: p.room.id, mode: 'friend', expectedPreviousSeq: p.seq }, s)));
|
||||
}
|
||||
for (let i = 0; i < seconds * 25; i++) {
|
||||
await sleep(steadyStart + i * 40 - Date.now()); launch(cycle(i));
|
||||
if (i && i % 1500 === 0) console.log(JSON.stringify({ phase, elapsedSeconds: Math.round((Date.now() - steadyStart) / 1000), completedPairCycles: i, inflight: inflight.size, failures }));
|
||||
}
|
||||
await Promise.all(inflight);
|
||||
const elapsedMs = Date.now() - steadyStart;
|
||||
const inspect = await new MongoClient(process.env.TEST_MONGODB_URI!).connect();
|
||||
try {
|
||||
const db = inspect.db(databaseName);
|
||||
const totals = await db.collection('game_sessions').aggregate([{ $group: { _id: null, sessions: { $sum: 1 }, wins: { $sum: '$stats.friend.wins' }, losses: { $sum: '$stats.friend.losses' }, best: { $max: '$stats.friend.best' } } }]).toArray();
|
||||
const percent = (values: number[], n: number) => values.sort((a,b) => a-b)[Math.min(values.length-1, Math.floor(values.length*n))] ?? null;
|
||||
const summary = Object.fromEntries(Object.entries(requests).map(([key,v]) => [key, { count: v.ms.length, status: v.status, p50: percent(v.ms,.5), p95: percent(v.ms,.95), p99: percent(v.ms,.99) }]));
|
||||
const steady = metrics.filter(m => m.phase === 'steady');
|
||||
const output = { scope: 'HTTP + Standalone MongoDB + transient signaling; no real RTC load or TURN', sessions: 3000, pairs: pairCount, requestedSeconds: seconds, elapsedMs, rampMs, signalingConnections, duplicateResults, failures, totals, legacyMatches: await db.collection('matches').countDocuments(),
|
||||
resources: { peakRssBytes: Math.max(...steady.map(m => m.rss)), meanCpuPercentOneCore: steady.reduce((n,m) => n+m.cpuPercent,0)/steady.length, peakWebSockets: Math.max(...metrics.map(m=>m.peakSockets)), steadyPeakWebSockets: Math.max(...steady.map(m=>m.sockets)), writeMethodCalls: metrics.at(-1)?.writes }, summary, metrics };
|
||||
await mkdir('docs/verification', { recursive: true }); await writeFile('docs/verification/webrtc-load.json', JSON.stringify(output, null, 2));
|
||||
console.log(JSON.stringify({ ...output, metrics: undefined }));
|
||||
// This invocation exclusively owns this randomly named test database.
|
||||
await db.dropDatabase();
|
||||
} finally { await inspect.close(); }
|
||||
} finally { child.send('stop'); }
|
||||
Reference in New Issue
Block a user