184 lines
7.7 KiB
TypeScript
184 lines
7.7 KiB
TypeScript
import { remoteFixture } from '../fixtures/remote-config.ts';
|
|
import assert from 'node:assert/strict';
|
|
import { readFileSync } from 'node:fs';
|
|
import { test } from 'node:test';
|
|
import type { RuntimeConfig } from '../../YouleNexus/assets/framework/config/runtime-config.ts';
|
|
import type { Clock } from '../../YouleNexus/assets/framework/net/heartbeat.ts';
|
|
import { WireClient } from '../../YouleNexus/assets/framework/net/wire-client.ts';
|
|
import { PlatformRuntime } from '../../YouleNexus/assets/framework/platform/runtime.ts';
|
|
import { Router } from '../../YouleNexus/assets/framework/protocol/router.ts';
|
|
import type { InboundMessage } from '../../YouleNexus/assets/framework/core/types/envelope.ts';
|
|
import type { GameHost } from '../../YouleNexus/assets/framework/sdk/index.ts';
|
|
import { FakeTransport } from '../helpers/fake-transport.ts';
|
|
|
|
const CONFIG: RuntimeConfig = {
|
|
mode: 'debug', isDebugger: true,
|
|
identity: { agentid: 'A', channelid: 'C', gameid: '41', version: 1, marketid: 4 },
|
|
source: 'remote', servers: ['ws://agent'], gameserver: 'https://fixture/config', ...remoteFixture(),
|
|
};
|
|
const ACCOUNT = { openid: 'o', nickname: 'n', avatar: 'a', sex: 0, province: '', city: '', unionid: 'u' };
|
|
const LOGIN = {
|
|
app: 'youle', route: 'agent', rpc: 'player_login', data: {
|
|
agentid: 'A', gameid: '41', openid: 'o', nickname: 'n', avatar: 'a', sex: 0,
|
|
province: '', city: '', unionid: 'u', version: 1, channelid: 'C', marketid: 4,
|
|
location: null, machineid: 'machine', machineroom: 'install',
|
|
},
|
|
};
|
|
|
|
function fixture(name: string): InboundMessage & { data: Record<string, any> } {
|
|
return JSON.parse(readFileSync(new URL(`../fixtures/contracts/${name}.json`, import.meta.url), 'utf8'));
|
|
}
|
|
|
|
function setup() {
|
|
const timers = new Map<object, { fn: () => void; ms: number }>();
|
|
const clock: Clock = {
|
|
setTimeout(fn, ms) { const handle = {}; timers.set(handle, { fn, ms }); return handle; },
|
|
clearTimeout(handle) { timers.delete(handle as object); },
|
|
};
|
|
const transports: FakeTransport[] = [];
|
|
const hosts: GameHost[] = [];
|
|
const restores: unknown[] = [];
|
|
const scenes: string[] = [];
|
|
const fatals: Error[] = [];
|
|
const gameMessages: unknown[] = [];
|
|
const runtime = new PlatformRuntime({
|
|
gameEntry: {
|
|
key: 'fixture', gameId: '41', route: 'fixture-game', resolveSeatCount: () => 4,
|
|
createModule: () => ({
|
|
attach(host) { hosts.push(host); },
|
|
handlePlatformEvent() {},
|
|
handleGameMessage(message) { gameMessages.push(message); },
|
|
restore(deskinfo) { restores.push(deskinfo); },
|
|
dispose() {},
|
|
}),
|
|
},
|
|
resolveRuntimeConfig: async () => CONFIG,
|
|
createWireClient(config) {
|
|
return new WireClient({ servers: config.servers, clock, transportFactory() {
|
|
const transport = new FakeTransport();
|
|
transports.push(transport);
|
|
return transport;
|
|
} });
|
|
},
|
|
scene: {
|
|
showLoading() { scenes.push('loading'); }, showLogin() { scenes.push('login'); },
|
|
showLobby() { scenes.push('lobby'); }, showRoom() { scenes.push('room'); },
|
|
showReconnect() { scenes.push('reconnect'); }, showKicked() { scenes.push('kicked'); },
|
|
showServerDenial() { assert.fail('Unexpected server denial'); },
|
|
showFatal(error) { fatals.push(error); },
|
|
},
|
|
loadResources: async () => {}, waitForMinimumDisplay: async () => {}, clock,
|
|
getLoginDeviceSnapshot: () => ({
|
|
location: null, machineid: 'machine', machineroom: 'install',
|
|
deviceLogin: { enabled: false }, loginPlayerId: { enabled: false },
|
|
}),
|
|
});
|
|
return {
|
|
runtime, transports, hosts, restores, scenes, fatals, gameMessages, timers,
|
|
fire(ms: number) {
|
|
const entry = [...timers].find(([, timer]) => timer.ms === ms);
|
|
assert.ok(entry, `missing ${ms}ms timer`);
|
|
timers.delete(entry[0]);
|
|
entry[1].fn();
|
|
},
|
|
};
|
|
}
|
|
|
|
test('modern login gates business packets and retries exact login after the four-second guard', async (t) => {
|
|
const s = setup();
|
|
t.after(() => s.runtime.stop());
|
|
const routed: InboundMessage[] = [];
|
|
const dispatch = Router.prototype.dispatch;
|
|
t.mock.method(Router.prototype, 'dispatch', function (this: Router, message: InboundMessage) {
|
|
routed.push(message);
|
|
return dispatch.call(this, message);
|
|
});
|
|
await s.runtime.start();
|
|
const first = s.transports[0]!;
|
|
await first.flush();
|
|
assert.deepEqual(first.sent, []);
|
|
s.runtime.login(ACCOUNT);
|
|
assert.deepEqual(first.sent.map(frame => JSON.parse(frame)), [LOGIN]);
|
|
|
|
first.serverPush(JSON.stringify(fixture('self-join-room')));
|
|
await first.flush();
|
|
assert.equal(routed.length, 1);
|
|
assert.equal(s.runtime.state.room.kind, 'outside');
|
|
assert.equal(s.hosts.length, 0);
|
|
s.fire(4000);
|
|
assert.equal(first.closeCalls, 1);
|
|
assert.equal(s.transports.length, 1);
|
|
s.fire(10000);
|
|
const second = s.transports[1]!;
|
|
await second.flush();
|
|
assert.deepEqual(second.sent.map(frame => JSON.parse(frame)), [LOGIN]);
|
|
|
|
// The obsolete connection cannot reach Router or consume the new login gate.
|
|
first.serverPush(JSON.stringify(fixture('player-login-room')));
|
|
await first.flush();
|
|
assert.equal(routed.length, 1);
|
|
second.serverPush(JSON.stringify(fixture('player-login-success')));
|
|
await second.flush();
|
|
assert.equal(routed.length, 2);
|
|
assert.equal(s.scenes[s.scenes.length - 1], 'lobby');
|
|
assert.equal(s.runtime.state.app.phase, 'logged-in');
|
|
assert.equal([...s.timers.values()].some(timer => timer.ms === 4000), false);
|
|
second.serverPush(JSON.stringify({ com: '@serverheartbeat' }));
|
|
await second.flush();
|
|
assert.equal(routed.length, 2);
|
|
assert.deepEqual(s.fatals, []);
|
|
assert.deepEqual(s.transports.flatMap(transport => transport.sent.map(frame => JSON.parse(frame))), [LOGIN, LOGIN]);
|
|
});
|
|
|
|
for (const deskinfo of [undefined, null, false, 0, '']) {
|
|
test(`modern room login does not restore falsey deskinfo: ${String(deskinfo)}`, async (t) => {
|
|
const s = setup();
|
|
t.after(() => s.runtime.stop());
|
|
await s.runtime.start();
|
|
const transport = s.transports[0]!;
|
|
await transport.flush();
|
|
s.runtime.login(ACCOUNT);
|
|
const login = fixture('player-login-room');
|
|
if (deskinfo === undefined) delete login.data.deskinfo;
|
|
else login.data.deskinfo = deskinfo;
|
|
transport.serverPush(JSON.stringify(login));
|
|
await transport.flush();
|
|
assert.equal(s.hosts.length, 1);
|
|
assert.equal(s.scenes[s.scenes.length - 1], 'room');
|
|
assert.deepEqual(s.restores, []);
|
|
assert.deepEqual(s.fatals, []);
|
|
assert.deepEqual(transport.sent.map(frame => JSON.parse(frame)), [LOGIN]);
|
|
});
|
|
}
|
|
|
|
test('another game route throws in the sole Router dispatch and becomes one terminal runtime error', async (t) => {
|
|
const s = setup();
|
|
t.after(() => s.runtime.stop());
|
|
const thrown: unknown[] = [];
|
|
const routed: InboundMessage[] = [];
|
|
const dispatch = Router.prototype.dispatch;
|
|
t.mock.method(Router.prototype, 'dispatch', function (this: Router, message: InboundMessage) {
|
|
routed.push(message);
|
|
try { return dispatch.call(this, message); }
|
|
catch (error) { thrown.push(error); throw error; }
|
|
});
|
|
await s.runtime.start();
|
|
const transport = s.transports[0]!;
|
|
await transport.flush();
|
|
s.runtime.login(ACCOUNT);
|
|
transport.serverPush(JSON.stringify(fixture('player-login-room')));
|
|
await transport.flush();
|
|
transport.serverPush(JSON.stringify({ route: 'another-game', rpc: 'deal', data: { cards: [1] } }));
|
|
await transport.flush();
|
|
|
|
assert.equal(routed.length, 2);
|
|
assert.equal(thrown.length, 1);
|
|
assert.match(String(thrown[0]), /route.*mismatch|another-game/i);
|
|
assert.equal(s.fatals.length, 1);
|
|
assert.equal(s.fatals[0], thrown[0]);
|
|
assert.deepEqual(s.gameMessages, []);
|
|
assert.equal(transport.closeCalls, 1);
|
|
assert.equal(s.timers.size, 0);
|
|
assert.deepEqual(transport.sent.map(frame => JSON.parse(frame)), [LOGIN]);
|
|
});
|