Prepare game updates and single-process PM2 deployment
This commit is contained in:
@@ -216,5 +216,5 @@ test('ranked results allow requeue after saving even when the opponent has left'
|
||||
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, '상대가 나갔습니다.');
|
||||
assert.equal(container.querySelector('.rematch-status').textContent, '상대방이 나갔습니다');
|
||||
});
|
||||
|
||||
+103
-11
@@ -1,3 +1,4 @@
|
||||
import { DEFAULT_GLOBAL_SETTINGS } from '../constants/global-settings.js';
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createHash } from 'node:crypto';
|
||||
@@ -231,13 +232,15 @@ test('the archived Yamcha function remains byte-for-byte unchanged', () => {
|
||||
assert.equal(createHash('sha256').update(archived).digest('hex'), 'b540712bc652b3380812299c7889c64ad4b0ae18a070d5dfa9f42e157ea5be2c');
|
||||
});
|
||||
|
||||
function domHarness(t: import('node:test').TestContext) {
|
||||
function domHarness(t: import('node:test').TestContext, reducedMotion = true) {
|
||||
class Element {
|
||||
dataset: Record<string, string> = {};
|
||||
textContent = ''; innerHTML = ''; outerHTML = ''; hidden = false;
|
||||
children = new Map<string, Element>();
|
||||
style = { setProperty() {}, removeProperty() {} };
|
||||
classList = { toggle() {} };
|
||||
properties = new Map<string, string>();
|
||||
style = { setProperty: (key: string, value: string) => { this.properties.set(key, value); }, removeProperty: (key: string) => { this.properties.delete(key); } };
|
||||
classList = { toggle() {}, contains() { return false; } };
|
||||
getBoundingClientRect() { return { left: 0, width: 100 }; }
|
||||
setAttribute() {} addEventListener() {} showModal() {} close() {} remove() {}
|
||||
focus() { doc.activeElement = this; }
|
||||
append(node: Element) { appended.push(node); }
|
||||
@@ -251,7 +254,7 @@ function domHarness(t: import('node:test').TestContext) {
|
||||
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() {} }),
|
||||
matchMedia: () => ({ matches: reducedMotion, addEventListener() {}, removeEventListener() {} }),
|
||||
} };
|
||||
for (const [key, value] of Object.entries(globals)) {
|
||||
const previous = Object.getOwnPropertyDescriptor(globalThis, key);
|
||||
@@ -289,26 +292,115 @@ test('match updates react to hair and equipment changes without rebuilding uncha
|
||||
} finally { view.destroy(); }
|
||||
});
|
||||
|
||||
test('an open cutscene refreshes changed appearance art without restarting its stage or result control', t => {
|
||||
const { appended, state } = domHarness(t);
|
||||
test('an open cutscene refreshes changed appearance art without restarting its stage', t => {
|
||||
const { appended, state } = domHarness(t, false);
|
||||
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.reveal = { matchId: state.matchId, turnId: 1, actions: ['blast', 'charge'], energyBefore: [3, 3], energy: [2, 3], hitIndex: 1, winnerIndex: 0, revealedAt: state.clockNow, revealEndsAt: state.clockNow + 1200 };
|
||||
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');
|
||||
const dialog = appended[0];
|
||||
assert.equal(dialog.dataset.stage, 'attack');
|
||||
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);
|
||||
assert.equal(dialog.dataset.stage, 'attack');
|
||||
page.outerHTML += '<!-- retained -->';
|
||||
cutscene.update({ ...state, version: 3 });
|
||||
assert.ok(page.outerHTML.endsWith('<!-- retained -->'));
|
||||
assert.equal(appended.length, 1);
|
||||
} finally { cutscene.destroy(); }
|
||||
});
|
||||
|
||||
test('global cutscene speed scales stages and CSS timing while respecting result availability', t => {
|
||||
const { appended, state } = domHarness(t, false);
|
||||
const cases = [
|
||||
[1, 600, 'attack'], [2, 600, 'impact'], [2, 1250, 'reaction'],
|
||||
[2, 1550, 'fallen'], [2, 1850, 'complete'], [0.5, 1850, 'attack'],
|
||||
[4, 950, 'fallen'], [4, 1250, 'complete'],
|
||||
] as const;
|
||||
for (const [speed, elapsed, expected] of cases) {
|
||||
state.matchId = `speed-${speed}-${elapsed}`; state.phase = 'finished';
|
||||
state.settings = { ...DEFAULT_GLOBAL_SETTINGS, revision: 2, turnDurationMs: 3000, finishCutsceneSpeed: speed };
|
||||
const revealedAt = state.clockNow - elapsed;
|
||||
state.reveal = { matchId: state.matchId, turnId: 1, actions: ['beam', 'guard'], energyBefore: [3, 3], energy: [0, 3], hitIndex: 1, winnerIndex: 0, revealedAt, revealEndsAt: revealedAt + 1200 };
|
||||
state.finished = { matchId: state.matchId, winnerIndex: 0, reason: 'hit', stats: emptyPlayerStats(), revealEndsAt: state.reveal.revealEndsAt, saved: true };
|
||||
let transitions = 0;
|
||||
const cutscene = createFinishCutscene(() => { transitions++; });
|
||||
try {
|
||||
cutscene.update(state);
|
||||
const dialog = appended.at(-1)!;
|
||||
assert.equal(dialog.dataset.stage, expected, `${speed}x at ${elapsed}ms`);
|
||||
assert.equal(dialog.properties.get('--cutscene-time-scale'), String(1 / speed));
|
||||
assert.equal(transitions, expected === 'complete' ? 1 : 0);
|
||||
assert.equal(dialog.innerHTML.includes('cutscene-result'), false);
|
||||
} finally { cutscene.destroy(); }
|
||||
}
|
||||
});
|
||||
|
||||
for (const reducedMotion of [false, true]) test(`cutscene timer automatically opens results once (reduced motion: ${reducedMotion})`, t => {
|
||||
const { state } = domHarness(t, reducedMotion);
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
let now = 0, transitions = 0;
|
||||
t.mock.method(performance, 'now', () => now);
|
||||
state.matchId = `automatic-result-${reducedMotion}`; state.phase = 'finished';
|
||||
state.reveal = { matchId: state.matchId, turnId: 1, actions: ['blast', 'charge'], energyBefore: [1, 1], energy: [0, 1], hitIndex: 1, winnerIndex: 0,
|
||||
revealedAt: state.clockNow, revealEndsAt: state.clockNow + 1200 };
|
||||
state.finished = { matchId: state.matchId, winnerIndex: 0, reason: 'hit', stats: emptyPlayerStats(), revealEndsAt: state.reveal.revealEndsAt, saved: true };
|
||||
const cutscene = createFinishCutscene(() => { transitions++; });
|
||||
try {
|
||||
cutscene.update(state);
|
||||
const end = reducedMotion ? 1200 : 3600;
|
||||
for (now = 1; now < end; now++) t.mock.timers.tick(1);
|
||||
assert.equal(transitions, 0);
|
||||
t.mock.timers.tick(1);
|
||||
assert.equal(transitions, 1);
|
||||
cutscene.update(state);
|
||||
now += 5000; t.mock.timers.tick(5000);
|
||||
assert.equal(transitions, 1);
|
||||
} finally { cutscene.destroy(); }
|
||||
});
|
||||
|
||||
test('rematch updates cannot replay the final attack or hide an already displayed result', t => {
|
||||
const { container, actors, state } = domHarness(t, false);
|
||||
state.matchId = 'rematch-final-reveal'; state.phase = 'finished';
|
||||
state.reveal = { matchId: state.matchId, turnId: 2, actions: ['blast', 'charge'], energyBefore: [1, 1], energy: [0, 1], hitIndex: 1, winnerIndex: 0,
|
||||
revealedAt: state.clockNow - 5000, revealEndsAt: state.clockNow - 3800 };
|
||||
state.finished = { matchId: state.matchId, winnerIndex: 0, reason: 'hit', stats: emptyPlayerStats(), revealEndsAt: state.reveal.revealEndsAt, saved: true };
|
||||
const view = createMatchView(container as unknown as HTMLElement, async () => true, () => {}, () => {});
|
||||
try {
|
||||
view.update(state);
|
||||
assert.equal(container.querySelector('.match-result').hidden, false);
|
||||
for (const clockNow of [state.clockNow + 1000, state.reveal.revealedAt + 100]) {
|
||||
view.update({ ...state, version: 2, clockNow, rematch: [false, true] });
|
||||
assert.equal(actors[0].dataset.action, undefined);
|
||||
assert.equal(actors[1].dataset.hit, undefined);
|
||||
assert.equal(container.querySelector('.match-result').hidden, false);
|
||||
assert.equal(container.querySelector('.rematch-status').textContent, '상대가 재대전을 요청했습니다.');
|
||||
}
|
||||
const nextId = 'rematch-next-round';
|
||||
view.update({ ...state, matchId: nextId, version: 1, phase: 'revealing', finished: undefined,
|
||||
reveal: { ...state.reveal, matchId: nextId, revealedAt: state.clockNow, revealEndsAt: state.clockNow + 1200 } });
|
||||
assert.equal(actors[0].dataset.action, 'blast', 'the next match still plays its own actions');
|
||||
} finally { view.destroy(); }
|
||||
});
|
||||
|
||||
test('a dismissed finisher stays closed when the opponent disconnects after the result', t => {
|
||||
const { appended, state } = domHarness(t, false);
|
||||
state.matchId = 'dismissed-peer-result'; state.phase = 'finished';
|
||||
state.reveal = { matchId: state.matchId, turnId: 2, actions: ['blast', 'charge'], energyBefore: [1, 1], energy: [0, 1], hitIndex: 1, winnerIndex: 0,
|
||||
revealedAt: state.clockNow - 5000, revealEndsAt: state.clockNow - 3800 };
|
||||
state.finished = { matchId: state.matchId, winnerIndex: 0, reason: 'hit', stats: emptyPlayerStats(), revealEndsAt: state.reveal.revealEndsAt, saved: true };
|
||||
let dismissals = 0;
|
||||
const cutscene = createFinishCutscene(() => { dismissals++; });
|
||||
try {
|
||||
cutscene.update(state);
|
||||
assert.equal(appended[0].dataset.stage, 'complete');
|
||||
assert.equal(dismissals, 1, 'completion automatically opens the result');
|
||||
cutscene.update({ ...state, version: 2, opponentPresent: false, clockNow: state.clockNow + 1000 });
|
||||
assert.equal(dismissals, 1); assert.equal(appended.length, 1);
|
||||
} finally { cutscene.destroy(); }
|
||||
});
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { randomBytes, randomUUID } from 'node:crypto';
|
||||
import { MongoClient } from 'mongodb';
|
||||
import { DEFAULT_GLOBAL_SETTINGS, GLOBAL_SETTINGS_CACHE_KEY } from '../constants/global-settings.js';
|
||||
import { createGlobalSettingsCache } from '../src/client/global-settings.js';
|
||||
import { createGameServer } from '../src/server/create-server.js';
|
||||
import { createGlobalSettingsStore } from '../src/server/global-settings.js';
|
||||
import { createGameSessionStore } from '../src/server/game-session-store.js';
|
||||
import { createRoomStore } from '../src/server/rooms.js';
|
||||
import { createMatchEngine } from '../src/shared/match-engine.js';
|
||||
|
||||
test('browser caches settings, checks revision and retains valid settings through failures', async () => {
|
||||
const memory = new Map<string, string>(), urls: string[] = [];
|
||||
let settings = { ...DEFAULT_GLOBAL_SETTINGS }, offline = false, writes = 0;
|
||||
const storage = { getItem: (key: string) => memory.get(key) ?? null, setItem: (key: string, value: string) => { writes++; memory.set(key, value); } };
|
||||
const send = async (input: any) => {
|
||||
urls.push(String(input)); if (offline) throw new Error('offline');
|
||||
const revision = new URL(String(input), 'http://test').searchParams.get('revision');
|
||||
return Response.json({ ok: true, data: { revision: settings.revision, ...(Number(revision) === settings.revision ? {} : { settings }) } });
|
||||
};
|
||||
const cache = createGlobalSettingsCache(storage, send);
|
||||
assert.deepEqual(await cache.refresh(), settings); assert.equal(writes, 1);
|
||||
const reloaded = createGlobalSettingsCache(storage, send);
|
||||
assert.deepEqual(await reloaded.refresh(), settings); assert.equal(urls.at(-1), '/api/global-settings?revision=1'); assert.equal(writes, 1);
|
||||
settings = { ...DEFAULT_GLOBAL_SETTINGS, revision: 2, turnDurationMs: 2000, finishCutsceneSpeed: 2 };
|
||||
assert.deepEqual(await reloaded.refresh(), settings); assert.equal(writes, 2);
|
||||
offline = true; assert.deepEqual(await reloaded.refresh(), settings);
|
||||
memory.set(GLOBAL_SETTINGS_CACHE_KEY, '{bad json');
|
||||
assert.deepEqual(await createGlobalSettingsCache(storage, send).refresh(), DEFAULT_GLOBAL_SETTINGS);
|
||||
});
|
||||
|
||||
test('match settings survive reload and early resolution still rejects missing choices', () => {
|
||||
let now = 0;
|
||||
const settings = { ...DEFAULT_GLOBAL_SETTINGS, revision: 2, turnDurationMs: 5000, finishCutsceneSpeed: 2 };
|
||||
const config = { matchId: 'settings', mode: 'friend' as const, selfIndex: 0 as const, clock: { now: () => now } };
|
||||
const engine = createMatchEngine({ ...config, settings });
|
||||
engine.start(); now = 3000; engine.beginTurn();
|
||||
assert.equal(engine.view().deadline, 8000);
|
||||
assert.throws(() => engine.resolve(['charge', null]), /BEFORE_DEADLINE/);
|
||||
engine.resolve(['charge', 'charge']); assert.equal(engine.view().phase, 'revealing');
|
||||
const restored = createMatchEngine(config); restored.restore(engine.checkpoint());
|
||||
now = 4200; restored.beginTurn();
|
||||
assert.equal(restored.view().deadline, 9200); assert.deepEqual(restored.view().settings, settings);
|
||||
});
|
||||
|
||||
test('DB settings update without restart and friend matches share an immutable snapshot', { skip: !process.env.TEST_MONGODB_URI }, async () => {
|
||||
const databaseName = 'dragon_ball_test_settings_' + randomUUID().replaceAll('-', '');
|
||||
const client = await new MongoClient(process.env.TEST_MONGODB_URI!).connect(), db = client.db(databaseName);
|
||||
const app = await createGameServer({ mongoUri: process.env.TEST_MONGODB_URI!, databaseName, clientDirectory: 'dist/client', adminUser: 'test', adminPassword: 'test-password', environment: 'test' });
|
||||
const headers = { authorization: 'Basic ' + Buffer.from('test:test-password').toString('base64') };
|
||||
try {
|
||||
const store = createGlobalSettingsStore(db);
|
||||
assert.deepEqual(await store.get(), DEFAULT_GLOBAL_SETTINGS);
|
||||
assert.equal((await app.inject({ method: 'POST', url: '/api/admin/global-settings', payload: DEFAULT_GLOBAL_SETTINGS })).statusCode, 401);
|
||||
const updated = { ...DEFAULT_GLOBAL_SETTINGS, revision: 1, turnDurationMs: 5000, finishCutsceneSpeed: 2 };
|
||||
const save = await app.inject({ method: 'POST', url: '/api/admin/global-settings', headers, payload: updated });
|
||||
assert.equal(save.statusCode, 200); assert.equal(save.json().data.revision, 2);
|
||||
assert.equal((await app.inject({ method: 'POST', url: '/api/admin/global-settings', headers, payload: updated })).statusCode, 409);
|
||||
assert.equal((await app.inject({ method: 'POST', url: '/api/admin/global-settings', headers, payload: { ...updated, revision: 2, finishCutsceneSpeed: 0 } })).statusCode, 400);
|
||||
assert.equal((await app.inject('/api/global-settings?revision=1')).json().data.settings.turnDurationMs, 5000);
|
||||
assert.deepEqual((await app.inject('/api/global-settings?revision=2')).json().data, { revision: 2 });
|
||||
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'));
|
||||
const room = await rooms.create(a.gameSessionId, 'create'); await rooms.join(b.gameSessionId, room.code);
|
||||
const input = { requestId: 'register', matchId: room.currentMatchId, mode: 'friend' as const, roomId: room.id, expectedPreviousSeq: 0 };
|
||||
const first = await sessions.register(a, input);
|
||||
await store.update({ ...DEFAULT_GLOBAL_SETTINGS, revision: 2, turnDurationMs: 2000, finishCutsceneSpeed: 3 });
|
||||
const second = await sessions.register(b, input);
|
||||
assert.deepEqual(first.settings, second.settings); assert.equal(second.settings?.revision, 2);
|
||||
const c = await sessions.create(randomBytes(32).toString('base64url'));
|
||||
const bot = await sessions.register(c, { requestId: 'bot', matchId: 'bot', mode: 'bot', expectedPreviousSeq: 0 });
|
||||
assert.equal(bot.settings?.revision, 3); assert.equal(bot.settings?.finishCutsceneSpeed, 3);
|
||||
await db.collection('global_settings').updateOne({ _id: 'global' as any }, { $unset: { matchingDelayMs: '', matchSuccessDelayMs: '', matchPreparationDelayMs: '' } });
|
||||
await store.initialize();
|
||||
const migrated = await store.get();
|
||||
assert.equal(migrated.matchingDelayMs, 1000); assert.equal(migrated.matchSuccessDelayMs, 1000);
|
||||
assert.equal(migrated.matchPreparationDelayMs, 3000);
|
||||
assert.equal(migrated.finishCutsceneSpeed, 3); assert.equal(migrated.turnDurationMs, 2000);
|
||||
assert.ok(migrated.revision > 3);
|
||||
await store.initialize(); assert.deepEqual(await store.get(), migrated);
|
||||
const changed = await store.update({ ...migrated, matchingDelayMs: 4000, matchSuccessDelayMs: 2000, matchPreparationDelayMs: 5000 });
|
||||
assert.equal(changed.matchingDelayMs, 4000); assert.equal(changed.matchSuccessDelayMs, 2000);
|
||||
assert.equal((await store.get()).matchPreparationDelayMs, 5000);
|
||||
await assert.rejects(store.update({ ...changed, matchPreparationDelayMs: -1 }), /INVALID_SETTINGS/);
|
||||
await assert.rejects(store.update({ ...changed, matchingDelayMs: -1 }), /INVALID_SETTINGS/);
|
||||
} finally { await app.close(); await db.dropDatabase(); await client.close(); }
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import Fastify from 'fastify';
|
||||
import { createHttpOptions, registerHttpOperations, parseTrustProxy, logSafeError } from '../src/server/http-operations.js';
|
||||
import { gameRoute } from '../src/server/game-api.js';
|
||||
import { registerAdmin } from '../src/server/admin.js';
|
||||
|
||||
test('session limits precede handlers, ignore spoofed forwarding and expire', async t => {
|
||||
let now = 0, calls = 0;
|
||||
const app = Fastify(createHttpOptions({ environment: 'test' }));
|
||||
t.after(() => app.close());
|
||||
registerHttpOperations(app, { now: () => now });
|
||||
gameRoute(app, 'POST', '/api/game-sessions', () => { calls++; return {}; });
|
||||
for (let i = 0; i < 30; i++) assert.equal((await app.inject({ method: 'POST', url: '/api/game-sessions', headers: { 'x-forwarded-for': `192.0.2.${i}` } })).statusCode, 200);
|
||||
now = 1001;
|
||||
const limited = await app.inject({ method: 'POST', url: '/api/game-sessions?secret=x' });
|
||||
assert.equal(limited.statusCode, 429); assert.equal(limited.headers['retry-after'], '59');
|
||||
assert.match(limited.json().message, /다시/); assert.equal(calls, 30);
|
||||
now = 60000;
|
||||
assert.equal((await app.inject({ method: 'POST', url: '/api/game-sessions' })).statusCode, 200);
|
||||
});
|
||||
|
||||
test('global budget allows shared IP polling and limits all API routes', async t => {
|
||||
const app = Fastify(createHttpOptions({ environment: 'test' })); t.after(() => app.close());
|
||||
registerHttpOperations(app, { now: () => 0 });
|
||||
app.get('/api/poll', () => ({})); app.get('/health', () => ({ status: 'ok' }));
|
||||
for (let i = 0; i < 1200; i++) assert.equal((await app.inject('/api/poll')).statusCode, 200);
|
||||
assert.equal((await app.inject('/api/unknown')).statusCode, 429);
|
||||
assert.equal((await app.inject('/health')).statusCode, 200);
|
||||
});
|
||||
|
||||
test('admin HTML and API share a budget before authentication', async t => {
|
||||
const app = Fastify(createHttpOptions({ environment: 'test' })); t.after(() => app.close());
|
||||
registerHttpOperations(app, { now: () => 0 });
|
||||
registerAdmin(app, {} as any, {} as any, {} as any, { user: 'test', password: 'password' }, '.');
|
||||
for (let i = 0; i < 60; i++) assert.equal((await app.inject(i % 2 ? '/admin/analytics' : '/api/admin/analytics/live')).statusCode, 401);
|
||||
assert.equal((await app.inject('/api/admin/analytics/report')).statusCode, 429);
|
||||
});
|
||||
|
||||
test('explicit loopback proxy trusts only the nearest untrusted hop', async t => {
|
||||
const app = Fastify(createHttpOptions({ environment: 'test', trustProxy: parseTrustProxy('loopback') })); t.after(() => app.close());
|
||||
registerHttpOperations(app, { now: () => 0 });
|
||||
app.post('/api/game-sessions', request => ({ ip: request.ip }));
|
||||
for (let i = 0; i < 30; i++) assert.equal((await app.inject({ method: 'POST', url: '/api/game-sessions', remoteAddress: '127.0.0.1', headers: { 'x-forwarded-for': `198.51.100.${i}, 192.0.2.1` } })).statusCode, 200);
|
||||
assert.equal((await app.inject({ method: 'POST', url: '/api/game-sessions', headers: { 'x-forwarded-for': '192.0.2.1' } })).statusCode, 429);
|
||||
const direct = await app.inject({ method: 'POST', url: '/api/game-sessions', remoteAddress: '192.0.2.2', headers: { 'x-forwarded-for': '192.0.2.1' } });
|
||||
assert.equal(direct.json().ip, '192.0.2.2');
|
||||
assert.equal(parseTrustProxy(undefined), false);
|
||||
for (const unsafe of ['true', '*', '0.0.0.0/0', '::/0', 'loopback,']) assert.throws(() => parseTrustProxy(unsafe));
|
||||
});
|
||||
|
||||
test('bounded IP storage fails closed and recovers after expiry', async t => {
|
||||
let now = 0;
|
||||
const app = Fastify(createHttpOptions({ environment: 'test' })); t.after(() => app.close());
|
||||
registerHttpOperations(app, { now: () => now, maxIps: 2 }); app.get('/api/poll', () => ({}));
|
||||
for (const ip of ['192.0.2.1', '192.0.2.2']) assert.equal((await app.inject({ url: '/api/poll', remoteAddress: ip })).statusCode, 200);
|
||||
assert.equal((await app.inject({ url: '/api/poll', remoteAddress: '192.0.2.3' })).statusCode, 429);
|
||||
now = 60000;
|
||||
assert.equal((await app.inject({ url: '/api/poll', remoteAddress: '192.0.2.3' })).statusCode, 200);
|
||||
});
|
||||
|
||||
test('unexpected HTTP, game, readiness and background errors never expose secrets', async t => {
|
||||
const lines: string[] = [], secret = 'VERY_SECRET_CREDENTIAL';
|
||||
const options = createHttpOptions({ environment: 'production' });
|
||||
const app = Fastify({ ...options, logger: { ...options.logger as object, stream: { write: (line: string) => lines.push(line) } } });
|
||||
t.after(() => app.close());
|
||||
registerHttpOperations(app, { ready: async () => { throw new Error(secret); } });
|
||||
const fail = () => { throw Object.assign(new Error(secret), { code: secret, name: secret, cause: { password: secret } }); };
|
||||
app.post('/api/plain/:id', fail); gameRoute(app, 'POST', '/api/game/:id', fail);
|
||||
for (const path of ['plain', 'game']) {
|
||||
const response = await app.inject({ method: 'POST', url: `/api/${path}/${secret}?token=${secret}`, headers: { authorization: `Bearer ${secret}`, 'x-request-id': secret }, payload: { password: secret } });
|
||||
assert.equal(response.statusCode, 500); assert.ok(!response.body.includes(secret));
|
||||
}
|
||||
assert.equal((await app.inject('/ready')).statusCode, 503);
|
||||
logSafeError(app.log, 'ranked_background', new Error(secret));
|
||||
assert.ok(!lines.join('').includes(secret));
|
||||
const errors = lines.map(line => JSON.parse(line)).filter(line => line.level === 50);
|
||||
assert.equal(errors.length, 4); assert.equal(errors[0].route, '/api/plain/:id');
|
||||
});
|
||||
|
||||
test('readiness succeeds and malformed bodies receive sanitized client errors', async t => {
|
||||
const app = Fastify(createHttpOptions({ environment: 'test' })); t.after(() => app.close());
|
||||
registerHttpOperations(app, { ready: async () => {} }); app.post('/api/body', () => ({}));
|
||||
assert.equal((await app.inject('/ready')).statusCode, 200);
|
||||
const response = await app.inject({ method: 'POST', url: '/api/body', headers: { 'content-type': 'application/json' }, payload: '{"secret":"UNSAFE' });
|
||||
assert.equal(response.statusCode, 400); assert.ok(!response.body.includes('UNSAFE'));
|
||||
});
|
||||
@@ -1,9 +1,13 @@
|
||||
import { DEFAULT_GLOBAL_SETTINGS } from '../constants/global-settings.js';
|
||||
import { matchIntroductionDuration, matchIntroductionMessage } from '../src/shared/match-introduction.js';
|
||||
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';
|
||||
import { createMatchEngine } from '../src/shared/match-engine.js';
|
||||
import { RANKED_POLL_MS } from '../constants/ranked.js';
|
||||
|
||||
// A selector/event surface, not an HTML parser or rendering implementation.
|
||||
// The test invokes the production selection/equip handlers and observes the
|
||||
@@ -34,6 +38,57 @@ function documentSurface() {
|
||||
return document;
|
||||
}
|
||||
|
||||
for (const initiallyQueued of [false, true]) test(`ranked introduction follows server countdown (queued: ${initiallyQueued})`, 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');
|
||||
});
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
let now = 0;
|
||||
const engine = createMatchEngine({ matchId: 'ranked-intro', mode: 'friend', selfIndex: 0, clock: { now: () => now } });
|
||||
engine.start(matchIntroductionDuration(DEFAULT_GLOBAL_SETTINGS, true));
|
||||
const profile: PlayerProfile = {
|
||||
playerId: 'player-a', displayName: 'Alpha', appearance: normalizeAppearance(undefined),
|
||||
stats: { wins: 0, losses: 0, draws: 0, current: 0, best: 0 }, unlockedColors: ['white'], unlockedEquipment: [],
|
||||
};
|
||||
const screens: string[] = [];
|
||||
const api = { async request(path: string) {
|
||||
if (path === '/api/profile') return profile;
|
||||
if (path === '/api/ranked/cancel' || path === '/api/ranked/leave') return { status: 'idle', profile };
|
||||
if (initiallyQueued && path === '/api/ranked/queue') return { status: 'queued', profile };
|
||||
if (now >= engine.view().startsAt) engine.beginTurn();
|
||||
return { status: 'matched', profile, match: { ...engine.view(), ranked: true } };
|
||||
} } as unknown as GameApi;
|
||||
const lobby = createLobbyFeatures({ api, onQueue: message => screens.push(message),
|
||||
onMatch: state => screens.push(matchIntroductionMessage(state, state.clockNow) ?? state.phase), onHome: () => screens.push('home'),
|
||||
onAppearance() {}, onAvailabilityChange() {}, onError(error) { throw error; },
|
||||
});
|
||||
const flush = async () => { for (let i = 0; i < 5; i++) await Promise.resolve(); };
|
||||
const advance = async (ms: number) => {
|
||||
for (let elapsed = 0; elapsed < ms; elapsed += RANKED_POLL_MS) {
|
||||
now += RANKED_POLL_MS; t.mock.timers.tick(RANKED_POLL_MS); await flush();
|
||||
}
|
||||
};
|
||||
await lobby.initialize(false);
|
||||
document.getElementById('ranked-start').onclick!();
|
||||
assert.equal(screens.at(-1), '랭킹대전 매칭 중…', 'show search before the queue response');
|
||||
await flush();
|
||||
assert.equal(screens.includes('countdown'), false);
|
||||
await advance(DEFAULT_GLOBAL_SETTINGS.matchingDelayMs);
|
||||
assert.equal(screens.at(-1), '랭킹대전 매칭 성공!');
|
||||
await advance(DEFAULT_GLOBAL_SETTINGS.matchSuccessDelayMs);
|
||||
assert.equal(screens.at(-1), '랭킹대전 준비 중…');
|
||||
await advance(3000);
|
||||
assert.equal(screens.at(-1), 'selecting');
|
||||
assert.equal(engine.view().deadline - now, engine.view().settings!.turnDurationMs, 'retain the full first turn');
|
||||
await lobby.leave();
|
||||
const count = screens.length;
|
||||
await advance(1000);
|
||||
assert.equal(screens.length, count, 'leaving stops presentation updates');
|
||||
});
|
||||
|
||||
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 });
|
||||
|
||||
@@ -6,12 +6,13 @@ 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 { createGlobalSettingsCache } from '../src/client/global-settings.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') {
|
||||
async function setup(kind: 'expired' | 'orphan' | 'rematch-wait' | 'result') {
|
||||
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 } : {}) };
|
||||
@@ -36,8 +37,10 @@ async function setup(kind: 'expired' | 'orphan' | 'rematch-wait') {
|
||||
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: () => '',
|
||||
window: { addEventListener() {} }, document: { addEventListener() {}, 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),
|
||||
createGlobalSettingsCache: () => createGlobalSettingsCache(storage, send),
|
||||
createAppFooter: () => ({ setConnection() {}, openRules() {} }),
|
||||
createClientAnalytics: () => ({ ready() {}, screen() {}, gameFinished() {}, gameStarted() {}, error() {}, click() {} }),
|
||||
createResultReporter: (api: any) => ({ destroy() {}, resumePending: async () => {}, submit: api.record }),
|
||||
createMatchView: () => ({ update() {}, destroy() {} }), createPeer: () => ({}),
|
||||
@@ -45,7 +48,7 @@ async function setup(kind: 'expired' | 'orphan' | 'rematch-wait') {
|
||||
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();
|
||||
vm.createContext(ctx); vm.runInContext(compiled + '\nglobalThis.review={leave,boot,inspect:()=>({ready,currentScreen,currentRoom,currentMatch})};', 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 () => {
|
||||
@@ -61,3 +64,13 @@ test('refresh restores HTTP rematch consent polling and starts an advanced round
|
||||
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']);
|
||||
});
|
||||
|
||||
test('a result monitors opponent departure without requesting a rematch', async () => {
|
||||
const h = await setup('result');
|
||||
assert.equal(h.polls.size, 1);
|
||||
assert.equal(h.ctx.review.inspect().currentMatch.opponentLeft, false);
|
||||
h.room.status = 'closed';
|
||||
[...h.polls.values()][0](); await flush();
|
||||
assert.equal(h.ctx.review.inspect().currentMatch.opponentLeft, true);
|
||||
assert.equal(h.calls.some(path => path.endsWith('/rematch')), false);
|
||||
});
|
||||
|
||||
@@ -21,9 +21,10 @@ test('local bot runs countdown, turns, result and persistence without Socket.IO'
|
||||
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]);
|
||||
assert.equal(h.controller.view().phase, 'revealing');
|
||||
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);
|
||||
await Promise.resolve();
|
||||
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');
|
||||
|
||||
@@ -14,7 +14,7 @@ test('countdown and turn deadlines gate actions without a server', () => {
|
||||
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.view().deadline, 7000);
|
||||
assert.equal(engine.lockAction('blast'), false);
|
||||
assert.equal(engine.lockAction('charge'), true);
|
||||
assert.equal(engine.lockAction('guard'), false);
|
||||
@@ -71,7 +71,7 @@ test('confirmed reveal timing and recovery keep energy while discarding an uncon
|
||||
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);
|
||||
assert.equal(engine.view().ownAction, null); assert.equal(engine.view().deadline, 14000);
|
||||
});
|
||||
|
||||
test('rollback and recovery keep published revisions increasing so the view accepts restored states', () => {
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { DEFAULT_GLOBAL_SETTINGS } from '../constants/global-settings.js';
|
||||
import { createMatchEngine } from '../src/shared/match-engine.js';
|
||||
import { matchIntroductionDuration, matchIntroductionMessage } from '../src/shared/match-introduction.js';
|
||||
|
||||
for (const ranked of [false, true]) test(`configurable introduction preserves preparation and first turn (ranked: ${ranked})`, () => {
|
||||
let now = 0;
|
||||
const settings = { ...DEFAULT_GLOBAL_SETTINGS, matchingDelayMs: 4000, matchSuccessDelayMs: 2000, matchPreparationDelayMs: 5000 };
|
||||
const engine = createMatchEngine({ matchId: 'intro', mode: 'friend', selfIndex: 0, clock: { now: () => now }, settings });
|
||||
engine.start(matchIntroductionDuration(settings, ranked));
|
||||
const message = () => matchIntroductionMessage({ ...engine.view(), ranked }, now);
|
||||
assert.equal(message(), ranked ? '랭킹대전 매칭 중…' : '친구 대전 매칭 성공!');
|
||||
if (ranked) { now = 4000; assert.equal(message(), '랭킹대전 매칭 성공!'); }
|
||||
now += 2000;
|
||||
assert.equal(message(), ranked ? '랭킹대전 준비 중…' : '친구 대전 준비 중…');
|
||||
assert.equal(engine.beginTurn(), false);
|
||||
now += 5000;
|
||||
assert.equal(engine.beginTurn(), true);
|
||||
assert.equal(message(), undefined);
|
||||
assert.equal(engine.view().deadline - now, settings.turnDurationMs);
|
||||
});
|
||||
+121
-9
@@ -32,7 +32,7 @@ class Wire {
|
||||
close() { this.connected = false; }
|
||||
disconnect() { this.connected = false; for (const h of this.states) h('disconnected'); }
|
||||
}
|
||||
function setup(onRematch?: () => Promise<void>, guestClockOffset = 0) {
|
||||
function setup(onRematch?: () => Promise<void>, guestClockOffset = 0, guestStartDelay = 0) {
|
||||
const clock = new ManualClock(); clock.time = 1000;
|
||||
const a = new Wire(), b = new Wire(); a.remote = b; b.remote = a;
|
||||
const records: ResultSubmission[][] = [[], []];
|
||||
@@ -40,7 +40,8 @@ function setup(onRematch?: () => Promise<void>, guestClockOffset = 0) {
|
||||
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;
|
||||
const localClock = slot === 1 ? { now: () => clock.now() + guestClockOffset,
|
||||
setTimeout: (fn: () => void, ms: number) => clock.setTimeout(fn, ms + (ms === 4000 || ms === 0 ? guestStartDelay : 0)), 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,
|
||||
@@ -51,9 +52,12 @@ function setup(onRematch?: () => Promise<void>, guestClockOffset = 0) {
|
||||
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);
|
||||
await Promise.all(h.controllers.map(c => c.start())); await h.clock.advance(4000);
|
||||
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);
|
||||
await a.submitAction('charge'); await h.clock.advance(1);
|
||||
assert.equal(a.view().phase, 'selecting');
|
||||
await b.submitAction('charge'); await h.clock.advance(1);
|
||||
assert.equal(a.view().phase, 'revealing'); assert.equal(b.view().phase, 'revealing');
|
||||
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');
|
||||
@@ -61,11 +65,28 @@ test('two peers agree turns and record complementary results without server turn
|
||||
} finally { h.close(); }
|
||||
});
|
||||
|
||||
test('an immediate choice survives the other browser starting the announced turn late', async () => {
|
||||
const h = setup(undefined, 0, 100), [a, b] = h.controllers;
|
||||
try {
|
||||
await Promise.all(h.controllers.map(c => c.start())); await h.clock.advance(0); await h.clock.advance(4000);
|
||||
assert.equal(a.view().phase, 'selecting'); assert.equal(b.view().phase, 'countdown');
|
||||
await a.submitAction('charge'); await h.clock.advance(0); await h.clock.advance(100);
|
||||
await b.submitAction('charge'); await h.clock.advance(1);
|
||||
assert.equal(a.view().phase, 'revealing'); assert.equal(b.view().phase, 'revealing');
|
||||
assert.deepEqual(a.view().energy, [1, 1]); assert.deepEqual(b.view().energy, [1, 1]);
|
||||
await h.clock.advance(1200);
|
||||
assert.equal(a.view().phase, 'selecting'); assert.equal(b.view().phase, 'revealing');
|
||||
await a.submitAction('blast'); await h.clock.advance(0); await h.clock.advance(100);
|
||||
await b.submitAction('charge'); await h.clock.advance(1);
|
||||
assert.equal(a.view().finished?.reason, 'hit'); assert.equal(b.view().finished?.reason, 'hit');
|
||||
} 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 Promise.all(h.controllers.map(c => c.start())); await h.clock.advance(4000);
|
||||
await a.submitAction('charge'); await b.submitAction('charge'); await h.clock.advance(1); await h.clock.advance(1200);
|
||||
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);
|
||||
@@ -73,6 +94,28 @@ test('loss of final presentation after digest acknowledgements preserves both ag
|
||||
} finally { h.close(); }
|
||||
});
|
||||
|
||||
for (const leavingSlot of [0, 1] as const) test(`leaving a finished match as slot ${leavingSlot} never restarts the other player's final reveal`, async () => {
|
||||
const h = setup(), [a, b] = h.controllers;
|
||||
const remainingSlot = leavingSlot === 0 ? 1 : 0;
|
||||
try {
|
||||
await Promise.all(h.controllers.map(c => c.start())); await h.clock.advance(4000);
|
||||
await a.submitAction('charge'); await b.submitAction('charge'); await h.clock.advance(1); await h.clock.advance(1200);
|
||||
await a.submitAction('blast'); await b.submitAction('charge'); await h.clock.advance(4000);
|
||||
const remaining = h.controllers[remainingSlot], before = remaining.view();
|
||||
assert.equal(before.finished?.reason, 'hit'); assert.equal(before.finished?.saved, true);
|
||||
assert.ok(before.reveal!.revealEndsAt < h.clock.now());
|
||||
h.controllers[leavingSlot].destroy();
|
||||
const wire = remainingSlot === 0 ? h.a : h.b;
|
||||
wire.disconnect(); await h.clock.advance(1);
|
||||
assert.equal(remaining.view().opponentPresent, false);
|
||||
assert.deepEqual(remaining.view().reveal, before.reveal);
|
||||
assert.deepEqual(remaining.view().finished, before.finished);
|
||||
wire.disconnect(); await h.clock.advance(1);
|
||||
assert.deepEqual(remaining.view().reveal, before.reveal);
|
||||
assert.deepEqual(h.records.map(records => records.length), [1, 1]);
|
||||
} finally { h.close(); }
|
||||
});
|
||||
|
||||
test('crossed explicit leave requests produce one draw per player instead of two wins', async () => {
|
||||
const h = setup();
|
||||
try {
|
||||
@@ -84,7 +127,7 @@ test('crossed explicit leave requests produce one draw per player instead of two
|
||||
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');
|
||||
await Promise.all(h.controllers.map(c => c.start())); await h.clock.advance(4000); 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);
|
||||
@@ -97,7 +140,7 @@ test('recovery starts a fresh epoch and ignores messages from the previous conne
|
||||
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);
|
||||
await Promise.all(h.controllers.map(c => c.start())); await h.clock.advance(4000);
|
||||
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');
|
||||
@@ -107,7 +150,7 @@ test('peers whose wall clocks differ by five seconds agree on deadline and timeo
|
||||
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);
|
||||
await Promise.all(h.controllers.map(c => c.start())); await h.clock.advance(4000);
|
||||
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);
|
||||
@@ -130,3 +173,72 @@ test('refresh of a mutually confirmed final result saves without reconnecting to
|
||||
finally { restored.destroy(); }
|
||||
} finally { h.close(); }
|
||||
});
|
||||
|
||||
test('silent packet loss near a deadline enters recovery before declaring a protocol error', async () => {
|
||||
const h = setup();
|
||||
try {
|
||||
await Promise.all(h.controllers.map(c => c.start())); await h.clock.advance(4000);
|
||||
await Promise.all(h.controllers.map(c => c.submitAction('charge'))); await h.clock.advance(1); await h.clock.advance(1200);
|
||||
// Last heartbeat was just delivered; no RTC state event arrives when packets stop.
|
||||
h.a.drop = h.b.drop = true;
|
||||
await h.clock.advance(17000);
|
||||
assert.ok(h.a.epoch > 0 && h.b.epoch > 0);
|
||||
assert.deepEqual(h.records.map(r => r.map(x => [x.outcome, x.reason])), [[['aborted', 'connection_failed']], [['aborted', 'connection_failed']]]);
|
||||
} finally { h.close(); }
|
||||
});
|
||||
|
||||
test('transport send failure at selection deadline attempts recovery', async () => {
|
||||
const h = setup();
|
||||
try {
|
||||
await Promise.all(h.controllers.map(c => c.start())); await h.clock.advance(4000);
|
||||
await h.clock.advance(2900);
|
||||
h.a.connected = h.b.connected = false;
|
||||
h.a.restart = h.b.restart = async () => { throw new Error('offline'); };
|
||||
await h.clock.advance(100);
|
||||
assert.deepEqual(h.records.map(r => r.map(x => x.reason)), [['connection_failed'], ['connection_failed']]);
|
||||
} finally { h.close(); }
|
||||
});
|
||||
|
||||
test('recovery blocks actions, ignores repeated disconnects and records interruption only once', async () => {
|
||||
const h = setup();
|
||||
try {
|
||||
await Promise.all(h.controllers.map(c => c.start())); await h.clock.advance(4000);
|
||||
h.a.drop = h.b.drop = true; h.a.disconnect(); h.b.disconnect();
|
||||
await h.clock.advance(1);
|
||||
assert.equal(await h.controllers[0].submitAction('charge'), false);
|
||||
h.a.disconnect(); h.b.disconnect(); await h.clock.advance(1);
|
||||
assert.equal(h.a.epoch, 1); assert.equal(h.b.epoch, 1);
|
||||
await h.clock.advance(10000);
|
||||
assert.deepEqual(h.records.map(r => r.length), [1, 1]);
|
||||
assert.deepEqual(h.records.map(r => r[0].outcome), ['aborted', 'aborted']);
|
||||
} finally { h.close(); }
|
||||
});
|
||||
|
||||
test('leaving during unavailable recovery settles and records once', async () => {
|
||||
const h = setup();
|
||||
try {
|
||||
await Promise.all(h.controllers.map(c => c.start())); await h.clock.advance(4000);
|
||||
h.a.drop = h.b.drop = true; h.a.disconnect(); h.b.disconnect(); await h.clock.advance(1);
|
||||
const leaving = h.controllers[0].leave(); await h.clock.advance(3000); await leaving;
|
||||
await h.clock.advance(10000);
|
||||
assert.deepEqual(h.records.map(r => r.map(x => x.outcome)), [['aborted'], ['aborted']]);
|
||||
} finally { h.close(); }
|
||||
});
|
||||
|
||||
test('loss of result agreement cannot display or save a speculative winner during recovery', async () => {
|
||||
const h = setup(); const [a, b] = h.controllers;
|
||||
try {
|
||||
await Promise.all(h.controllers.map(c => c.start())); await h.clock.advance(4000);
|
||||
await a.submitAction('charge'); await b.submitAction('charge'); await h.clock.advance(1); await h.clock.advance(1200);
|
||||
await a.submitAction('blast'); await b.submitAction('charge');
|
||||
h.a.intercept = h.b.intercept = message => message.type === 'turn:resolved';
|
||||
h.a.restart = h.b.restart = () => new Promise<void>(() => {});
|
||||
await h.clock.advance(7000);
|
||||
assert.deepEqual(h.controllers.map(c => c.view().finished), [undefined, undefined]);
|
||||
assert.deepEqual(h.controllers.map(c => c.view().opponentPresent), [false, false]);
|
||||
await Promise.all(h.controllers.map(c => c.retrySave()));
|
||||
assert.deepEqual(h.records.map(r => r.length), [0, 0]);
|
||||
await h.clock.advance(10000);
|
||||
assert.deepEqual(h.records.map(r => r.map(x => x.outcome)), [['aborted'], ['aborted']]);
|
||||
} finally { h.close(); }
|
||||
});
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
// Diagnostic harness for real-browser testing; excluded from production bundles.
|
||||
(() => {
|
||||
const NativePeer = window.RTCPeerConnection, NativeSocket = window.WebSocket;
|
||||
const trace = []; let sockets = 0, nextPeer = 0;
|
||||
const trace = [], channels = new Set(); let sockets = 0, nextPeer = 0, dropPackets = false;
|
||||
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);
|
||||
for (const [label, action] of [
|
||||
['채널 종료', () => { for (const channel of channels) if (channel.readyState === 'open') channel.close(); }],
|
||||
['패킷 차단', () => { dropPackets = true; report('packets-blocked'); }],
|
||||
['패킷 복구', () => { dropPackets = false; report('packets-restored'); }],
|
||||
]) {
|
||||
const button = document.createElement('button'); button.textContent = label;
|
||||
button.onclick = action; controls.append(button);
|
||||
}
|
||||
setInterval(() => {
|
||||
const mode = controls.querySelector('select').value;
|
||||
if (mode === 'none') return;
|
||||
@@ -36,6 +44,11 @@
|
||||
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) {
|
||||
channels.add(channel);
|
||||
channel.addEventListener('close', () => channels.delete(channel));
|
||||
const send = channel.send.bind(channel);
|
||||
channel.send = data => { if (!dropPackets) send(data); };
|
||||
channel.addEventListener('message', event => { if (dropPackets) event.stopImmediatePropagation(); }, { capture: true });
|
||||
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 => {
|
||||
|
||||
+231
-11
@@ -1,10 +1,12 @@
|
||||
import { DEFAULT_GLOBAL_SETTINGS } from '../constants/global-settings.js';
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { randomBytes, randomUUID } from 'node:crypto';
|
||||
import Fastify from 'fastify';
|
||||
import { MongoClient } from 'mongodb';
|
||||
import { MongoClient, type Db } from 'mongodb';
|
||||
import { MATCH_COUNTDOWN_MS, REVEAL_DURATION_MS, TURN_DURATION_MS } from '../constants/gameplay.js';
|
||||
import { RANKED_FINISHED_TTL_MS, RANKED_QUEUE_TTL_MS } from '../constants/ranked.js';
|
||||
import { RANKED_FINISHED_TTL_MS, RANKED_QUEUE_TTL_MS, RANKED_STATS_CACHE_LIMIT, RANKED_STATS_CACHE_TTL_MS,
|
||||
RANKED_LEADERBOARD_CACHE_LIMIT, RANKED_LEADERBOARD_CACHE_TTL_MS } from '../constants/ranked.js';
|
||||
import { createRankedService } from '../src/server/ranked-service.js';
|
||||
import { registerRankedApi } from '../src/server/ranked-api.js';
|
||||
import { createGameSessionStore } from '../src/server/game-session-store.js';
|
||||
@@ -15,6 +17,30 @@ import { EQUIPMENT_IDS } from '../constants/cosmetics.js';
|
||||
import { createRoomStore } from '../src/server/rooms.js';
|
||||
|
||||
const integration = { skip: !process.env.TEST_MONGODB_URI };
|
||||
function observeResultReads(db: Db, afterRead: (filter?: Record<string, unknown>) => Promise<void>) {
|
||||
const records = db.collection('ranked_matches');
|
||||
return new Proxy(db, { get(target, key) {
|
||||
if (key === 'collection') return (name: string) => name !== 'ranked_matches' ? target.collection(name) : new Proxy(records, {
|
||||
get(collection, method) {
|
||||
if (method === 'find') return (filter?: Record<string, unknown>) => {
|
||||
const cursor = collection.find(filter ?? {});
|
||||
const iterate = cursor[Symbol.asyncIterator].bind(cursor);
|
||||
cursor[Symbol.asyncIterator] = async function* () {
|
||||
const snapshot = [];
|
||||
for await (const result of iterate()) snapshot.push(result);
|
||||
await afterRead(filter);
|
||||
yield* snapshot;
|
||||
};
|
||||
return cursor;
|
||||
};
|
||||
const value = Reflect.get(collection, method);
|
||||
return typeof value === 'function' ? value.bind(collection) : value;
|
||||
},
|
||||
});
|
||||
const value = Reflect.get(target, key);
|
||||
return typeof value === 'function' ? value.bind(target) : value;
|
||||
} });
|
||||
}
|
||||
async function fixture() {
|
||||
const client = await new MongoClient(process.env.TEST_MONGODB_URI!, { serverSelectionTimeoutMS: 5000 }).connect();
|
||||
const db = client.db(`dragon_ball_test_ranked_${randomUUID().replaceAll('-', '')}`);
|
||||
@@ -324,7 +350,7 @@ test('server hides choices, checks costs and deadlines, resolves once and awards
|
||||
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 clock.advance(DEFAULT_GLOBAL_SETTINGS.matchingDelayMs + DEFAULT_GLOBAL_SETTINGS.matchSuccessDelayMs + 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');
|
||||
@@ -333,13 +359,13 @@ test('server hides choices, checks costs and deadlines, resolves once and awards
|
||||
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);
|
||||
const resolved = await s.action('player-b', b.gameSessionId, id, 1, 'charge');
|
||||
assert.equal(resolved.match?.phase, 'revealing');
|
||||
assert.deepEqual((await s.status('player-b', b.gameSessionId)).match?.reveal?.actions, ['charge', 'charge']);
|
||||
await clock.advance(REVEAL_DURATION_MS);
|
||||
await s.action('player-a', a.gameSessionId, id, 2, 'blast');
|
||||
await s.action('player-b', b.gameSessionId, id, 2, 'charge');
|
||||
await clock.advance(TURN_DURATION_MS);
|
||||
const finished = await s.action('player-b', b.gameSessionId, id, 2, 'charge');
|
||||
assert.equal(finished.match?.phase, 'finished');
|
||||
await Promise.all(Array.from({ length: 8 }, () => s.status('player-a', a.gameSessionId)));
|
||||
const ended = await s.status('player-b', b.gameSessionId);
|
||||
assert.equal(ended.status, 'matched'); assert.equal(ended.match?.phase, 'finished');
|
||||
@@ -366,7 +392,7 @@ test('finished matches retain results on reload then expire when abandoned', int
|
||||
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);
|
||||
await f.clock.advance(DEFAULT_GLOBAL_SETTINGS.matchingDelayMs + DEFAULT_GLOBAL_SETTINGS.matchSuccessDelayMs + 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);
|
||||
@@ -406,12 +432,14 @@ test('deadline rejects late actions and duplicate terminal writes survive an amb
|
||||
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 f.clock.advance(DEFAULT_GLOBAL_SETTINGS.matchingDelayMs + DEFAULT_GLOBAL_SETTINGS.matchSuccessDelayMs + MATCH_COUNTDOWN_MS);
|
||||
await s.action('player-a', f.a.gameSessionId, id, 1, 'guard');
|
||||
assert.equal((await s.leaderboard('player-a')).self, undefined);
|
||||
// Jump to the deadline without executing timers: the request itself must settle it.
|
||||
f.clock.time += TURN_DURATION_MS;
|
||||
await assert.rejects(s.action('player-b', f.b.gameSessionId, id, 1, 'guard'), /response lost after write/);
|
||||
assert.equal((await s.profile('player-a')).stats.wins, 1);
|
||||
assert.equal((await s.leaderboard('player-a')).self?.wins, 1);
|
||||
await assert.rejects(s.action('player-b', f.b.gameSessionId, id, 1, 'guard'), /INVALID_ACTION/);
|
||||
assert.equal((await s.status('player-b', f.b.gameSessionId)).match?.finished?.reason, 'timeout');
|
||||
assert.equal(await collection.countDocuments(), 1);
|
||||
@@ -457,7 +485,7 @@ test('timeouts progress without polling, unlock colors only from authoritative r
|
||||
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 clock.advance(DEFAULT_GLOBAL_SETTINGS.matchingDelayMs + DEFAULT_GLOBAL_SETTINGS.matchSuccessDelayMs + 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');
|
||||
@@ -536,7 +564,7 @@ test('a slow leaderboard scan does not block match actions or deadline processin
|
||||
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 f.clock.advance(DEFAULT_GLOBAL_SETTINGS.matchingDelayMs + DEFAULT_GLOBAL_SETTINGS.matchSuccessDelayMs + 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>;
|
||||
@@ -545,3 +573,195 @@ test('a slow leaderboard scan does not block match actions or deadline processin
|
||||
assert.equal(progressed, true, 'leaderboard must not own the gameplay operation lock');
|
||||
} finally { release(); await s.close(); await f.close(); }
|
||||
});
|
||||
|
||||
test('ranked polls reuse stats and concurrent leaderboard requests share a scan until a result is saved', integration, async () => {
|
||||
const f = await fixture();
|
||||
await f.service.close();
|
||||
const records = f.db.collection('ranked_matches');
|
||||
let histories = 0, boards = 0;
|
||||
const db = new Proxy(f.db, { get(target, key) {
|
||||
if (key === 'collection') return (name: string) => name !== 'ranked_matches' ? target.collection(name) : new Proxy(records, {
|
||||
get(collection, method) {
|
||||
if (method === 'find') return (filter?: Record<string, unknown>) => {
|
||||
const cursor = collection.find(filter ?? {});
|
||||
const iterate = cursor[Symbol.asyncIterator].bind(cursor);
|
||||
cursor[Symbol.asyncIterator] = async function* () {
|
||||
if (filter?.playerIds) histories++; else boards++;
|
||||
yield* iterate();
|
||||
};
|
||||
return cursor;
|
||||
};
|
||||
const value = Reflect.get(collection, method);
|
||||
return typeof value === 'function' ? value.bind(collection) : value;
|
||||
},
|
||||
});
|
||||
const value = Reflect.get(target, key);
|
||||
return typeof value === 'function' ? value.bind(target) : value;
|
||||
} });
|
||||
const s = await createRankedService(db, f.clock, 'test');
|
||||
try {
|
||||
await Promise.all(Array.from({ length: 8 }, () => s.profile('player-a')));
|
||||
for (let n = 0; n < 8; n++) await s.status('player-a', f.a.gameSessionId);
|
||||
assert.equal(histories, 1);
|
||||
await Promise.all(Array.from({ length: 8 }, () => s.leaderboard('player-a')));
|
||||
assert.equal(boards, 1);
|
||||
await s.join('player-a', f.a.gameSessionId);
|
||||
const id = (await s.join('player-b', f.b.gameSessionId)).match!.matchId;
|
||||
await s.leave('player-b', f.b.gameSessionId, id);
|
||||
const profile = await s.profile('player-a');
|
||||
assert.equal(profile.stats.wins, 1);
|
||||
profile.stats.wins = 999;
|
||||
assert.equal((await s.profile('player-a')).stats.wins, 1);
|
||||
const board = await s.leaderboard('player-a');
|
||||
assert.equal(board.self?.wins, 1);
|
||||
board.entries[0].wins = 999;
|
||||
assert.equal((await s.leaderboard('player-a')).self?.wins, 1);
|
||||
assert.equal((await s.leaderboard('player-b')).self?.rank, 2);
|
||||
assert.equal(boards, 2);
|
||||
assert.equal(histories, 4);
|
||||
f.clock.time += RANKED_LEADERBOARD_CACHE_TTL_MS;
|
||||
await s.leaderboard('player-a');
|
||||
assert.equal(boards, 3);
|
||||
assert.equal(histories, 4);
|
||||
f.clock.time += RANKED_STATS_CACHE_TTL_MS;
|
||||
await s.profile('player-a');
|
||||
assert.equal(histories, 5);
|
||||
// Exceed the retained-player budget, then revisit the evicted player.
|
||||
await Promise.all(Array.from({ length: RANKED_STATS_CACHE_LIMIT }, (_, n) => s.profile(`cache-player-${n}`)));
|
||||
const before = histories;
|
||||
await s.profile('player-a');
|
||||
assert.equal(histories, before + 1);
|
||||
} finally { await s.close(); await f.close(); }
|
||||
});
|
||||
|
||||
test('save during stalled ranked reads returns one snapshot without retrying or caching stale data', integration, async () => {
|
||||
const f = await fixture();
|
||||
await f.service.close();
|
||||
let release!: () => void, entered!: () => void;
|
||||
const gate = new Promise<void>(resolve => { release = resolve; });
|
||||
const started = new Promise<void>(resolve => { entered = resolve; });
|
||||
let hold = false, held = 0, histories = 0, boards = 0;
|
||||
const db = observeResultReads(f.db, async filter => {
|
||||
if (filter?.playerIds) histories++; else boards++;
|
||||
if (hold && (filter === undefined || filter.playerIds === 'spectator')) {
|
||||
if (++held === 2) entered();
|
||||
await gate;
|
||||
}
|
||||
});
|
||||
const s = await createRankedService(db, f.clock, 'test');
|
||||
try {
|
||||
await s.join('player-a', f.a.gameSessionId);
|
||||
const id = (await s.join('player-b', f.b.gameSessionId)).match!.matchId;
|
||||
hold = true;
|
||||
const board = s.leaderboard('player-a'), spectator = s.profile('spectator');
|
||||
await started;
|
||||
await s.leave('player-b', f.b.gameSessionId, id);
|
||||
const before = histories;
|
||||
release();
|
||||
assert.equal((await board).entries.length, 0, 'overlapping request returns its snapshot');
|
||||
assert.equal((await spectator).stats.wins, 0);
|
||||
assert.equal(boards, 1, 'a save must not restart the leaderboard scan');
|
||||
assert.equal(histories, before, 'unrelated saves must not restart personal history reads');
|
||||
hold = false;
|
||||
await s.profile('spectator');
|
||||
assert.equal(histories, before, 'unrelated player retains its cache');
|
||||
assert.equal((await s.leaderboard('player-a')).self?.wins, 1);
|
||||
assert.equal(boards, 2, 'overlapping stale snapshot must not be cached');
|
||||
assert.equal((await s.leaderboard('player-a')).self?.wins, 1);
|
||||
assert.equal(boards, 2);
|
||||
} finally { release(); await s.close(); await f.close(); }
|
||||
});
|
||||
|
||||
test('bounded leaderboard cache preserves exact self ranks outside the top entries and recovers failed scans', integration, async () => {
|
||||
const f = await fixture();
|
||||
await f.service.close();
|
||||
const count = RANKED_LEADERBOARD_CACHE_LIMIT + 2;
|
||||
const players = Array.from({ length: count }, (_, n) => `rank-${String(n).padStart(4, '0')}`);
|
||||
await f.db.collection<any>('player_profiles').insertMany(players.map(_id => ({ _id, nameVersion: 1, displayName: _id })));
|
||||
await f.db.collection<any>('ranked_matches').insertMany(players.map((id, order) => ({
|
||||
_id: `result-${id}`, playerIds: [id, 'opponent'], winnerIndex: 0, order,
|
||||
})));
|
||||
let scans = 0, fail = true;
|
||||
const db = observeResultReads(f.db, async filter => {
|
||||
if (filter === undefined) { scans++; if (fail) throw new Error('scan failed'); }
|
||||
});
|
||||
const s = await createRankedService(db, f.clock, 'test');
|
||||
try {
|
||||
await assert.rejects(s.leaderboard(players.at(-1)!), /scan failed/);
|
||||
fail = false;
|
||||
const board = await s.leaderboard(players.at(-1)!);
|
||||
assert.equal(board.entries.length, 100);
|
||||
assert.equal(board.self?.rank, count);
|
||||
assert.equal(board.self?.wins, 1);
|
||||
assert.equal((await s.leaderboard(players.at(-1)!)).self?.rank, count);
|
||||
assert.equal(scans, 2, 'failed promise is discarded and successful self rank is cached');
|
||||
// The requested tail entry evicted rank 1 from the bounded self cache.
|
||||
assert.equal((await s.leaderboard(players[0])).self?.rank, 1);
|
||||
assert.equal(scans, 3);
|
||||
assert.equal((await s.leaderboard(players.at(-2)!)).self?.rank, count - 1);
|
||||
assert.equal(scans, 4);
|
||||
} finally { await s.close(); await f.close(); }
|
||||
});
|
||||
|
||||
test('a personal history read overlapping its own result save cannot repopulate stale stats', integration, async () => {
|
||||
const f = await fixture();
|
||||
await f.service.close();
|
||||
let release!: () => void, entered!: () => void, hold = false;
|
||||
const gate = new Promise<void>(resolve => { release = resolve; });
|
||||
const started = new Promise<void>(resolve => { entered = resolve; });
|
||||
const db = observeResultReads(f.db, async filter => {
|
||||
if (hold && filter?.playerIds === 'player-a') { hold = false; entered(); await gate; }
|
||||
});
|
||||
const s = await createRankedService(db, f.clock, 'test');
|
||||
try {
|
||||
await s.join('player-a', f.a.gameSessionId);
|
||||
const id = (await s.join('player-b', f.b.gameSessionId)).match!.matchId;
|
||||
// Expire the cached stats without running timers; leave settles the overdue draw.
|
||||
f.clock.time += RANKED_STATS_CACHE_TTL_MS;
|
||||
hold = true;
|
||||
const old = s.profile('player-a');
|
||||
await started;
|
||||
await s.leave('player-b', f.b.gameSessionId, id);
|
||||
release();
|
||||
assert.equal((await old).stats.draws, 0);
|
||||
assert.equal((await s.profile('player-a')).stats.draws, 1);
|
||||
assert.equal((await s.profile('player-a')).stats.draws, 1);
|
||||
} finally { release(); await s.close(); await f.close(); }
|
||||
});
|
||||
|
||||
test('ranked timer reports only a safe failure code and retries persistence even if logging throws', integration, async () => {
|
||||
const f = await fixture();
|
||||
await f.service.close();
|
||||
const records = f.db.collection('ranked_matches');
|
||||
let fail = true, reported!: () => void;
|
||||
const logged = new Promise<void>(resolve => { reported = resolve; });
|
||||
const codes: string[] = [];
|
||||
const db = new Proxy(f.db, { get(target, key) {
|
||||
if (key === 'collection') return (name: string) => name !== 'ranked_matches' ? target.collection(name) : new Proxy(records, {
|
||||
get(collection, method) {
|
||||
if (method === 'updateOne') return async (...args: Parameters<typeof records.updateOne>) => {
|
||||
if (fail) { fail = false; throw new Error('mongodb://secret:password@host'); }
|
||||
return collection.updateOne(...args);
|
||||
};
|
||||
const value = Reflect.get(collection, method);
|
||||
return typeof value === 'function' ? value.bind(collection) : value;
|
||||
},
|
||||
});
|
||||
const value = Reflect.get(target, key);
|
||||
return typeof value === 'function' ? value.bind(target) : value;
|
||||
} });
|
||||
const s = await createRankedService(db, f.clock, 'test', code => {
|
||||
codes.push(code); reported(); throw new Error('logger failed');
|
||||
});
|
||||
try {
|
||||
await s.join('player-a', f.a.gameSessionId);
|
||||
await s.join('player-b', f.b.gameSessionId);
|
||||
await f.clock.advance(DEFAULT_GLOBAL_SETTINGS.matchingDelayMs + DEFAULT_GLOBAL_SETTINGS.matchSuccessDelayMs + MATCH_COUNTDOWN_MS + TURN_DURATION_MS);
|
||||
await logged;
|
||||
assert.deepEqual(codes, ['RANKED_BACKGROUND_FAILED']);
|
||||
assert.equal(await records.countDocuments(), 0);
|
||||
await f.clock.advance(1000);
|
||||
assert.equal((await s.status('player-a', f.a.gameSessionId)).profile.stats.draws, 1);
|
||||
assert.equal(await records.countDocuments(), 1);
|
||||
} finally { await s.close(); await f.close(); }
|
||||
});
|
||||
|
||||
@@ -16,6 +16,7 @@ test('HTTP game registration survives server restart without game timers and old
|
||||
try {
|
||||
const url = await app.listen({ host: '127.0.0.1', port: 0 });
|
||||
assert.equal((await app.inject('/health')).statusCode, 200);
|
||||
assert.equal((await app.inject('/ready')).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 };
|
||||
@@ -37,3 +38,20 @@ test('HTTP game registration survives server restart without game timers and old
|
||||
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(); } }
|
||||
});
|
||||
|
||||
test('production server enforces session limits before database creation', { skip: !uri }, async () => {
|
||||
const databaseName = 'dragon_ball_test_limits_' + randomUUID().replaceAll('-', '');
|
||||
const app = await createGameServer({ mongoUri: uri!, databaseName, clientDirectory: 'dist/client', environment: 'test' });
|
||||
const client = await new MongoClient(uri!).connect();
|
||||
try {
|
||||
for (let i = 0; i < 30; i++) {
|
||||
assert.equal((await app.inject({ method: 'POST', url: '/api/game-sessions', payload: {} })).statusCode, 400);
|
||||
}
|
||||
const response = await app.inject({ method: 'POST', url: '/api/game-sessions',
|
||||
payload: { createRequestId: randomBytes(32).toString('base64url') } });
|
||||
assert.equal(response.statusCode, 429);
|
||||
assert.ok(Number(response.headers['retry-after']) > 0);
|
||||
assert.equal(await client.db(databaseName).collection('players').countDocuments(), 0);
|
||||
assert.equal(await client.db(databaseName).collection('game_sessions').countDocuments(), 0);
|
||||
} finally { await app.close(); try { await client.db(databaseName).dropDatabase(); } finally { await client.close(); } }
|
||||
});
|
||||
|
||||
@@ -68,6 +68,14 @@ const scenarios: [string, () => void][] = [
|
||||
}],
|
||||
['패배', () => result(selfIndex === 0 ? 1 : 0)], ['승리', () => result(selfIndex)], ['무승부', () => result(null)],
|
||||
['기 부족', () => { clearTimeout(timer); show({ ...state(), energy: [0, 0] }); }],
|
||||
['연결 복구 중', () => { clearTimeout(timer); show({ ...state(), opponentPresent: false }); }],
|
||||
['준비 중 연결 끊김', () => { clearTimeout(timer); show({ ...state(), phase: 'countdown', opponentPresent: false, startsAt: Date.now() + MATCH_COUNTDOWN_MS }); }],
|
||||
['연결 복구 실패', () => {
|
||||
result(null); show({ ...current, aborted: true, finished: { ...current.finished!, reason: 'connection_failed' } });
|
||||
}],
|
||||
['중단 기록 저장 실패', () => {
|
||||
result(null); show({ ...current, aborted: true, saveStatus: 'failed', finished: { ...current.finished!, reason: 'connection_failed', saved: false } });
|
||||
}],
|
||||
['오른쪽 플레이어', () => { selfIndex = selfIndex === 0 ? 1 : 0; clearTimeout(timer); show(state()); }],
|
||||
];
|
||||
const picker = document.createElement('select'); picker.setAttribute('aria-label', '검증 상태');
|
||||
|
||||
Reference in New Issue
Block a user