refactor(platform): complete decoupled vertical runtime
This commit is contained in:
@@ -1,57 +1,181 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { NetClient } from '../../YouleNexus/assets/framework/net/net-client.ts';
|
||||
import { FakeTransport } from '../helpers/fake-transport.ts';
|
||||
import { parseLoginResponse } from '../../YouleNexus/assets/framework/protocol/login.ts';
|
||||
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 realishClock: Clock = { setTimeout: (fn, ms) => setTimeout(fn, ms), clearTimeout: (h: any) => clearTimeout(h) };
|
||||
const IDENTITY = { agentid: 1, gameid: 2, openid: 'o', nickname: 'n', avatar: 'a', sex: 0, province: '', city: '', unionid: 'u', version: 1, channelid: 0, marketid: 0 };
|
||||
const CONFIG: RuntimeConfig = {
|
||||
mode: 'debug', isDebugger: true,
|
||||
identity: { agentid: 'A', channelid: 'C', gameid: '41', version: 1, marketid: 4 },
|
||||
source: 'direct', servers: ['ws://agent'], gameserver: null, rawConfig: null,
|
||||
};
|
||||
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',
|
||||
},
|
||||
};
|
||||
|
||||
test('login 全流程:握手→登录→业务包,且 parseLoginResponse 识别房间恢复', async () => {
|
||||
const t = new FakeTransport();
|
||||
const client = new NetClient({ servers: 'ws://srv', transportFactory: () => t, clock: realishClock });
|
||||
client.setIdentity(IDENTITY);
|
||||
function fixture(name: string): InboundMessage & { data: Record<string, any> } {
|
||||
return JSON.parse(readFileSync(new URL(`../fixtures/contracts/${name}.json`, import.meta.url), 'utf8'));
|
||||
}
|
||||
|
||||
let loginData: any = null;
|
||||
const business: any[] = [];
|
||||
client.on('login', (d: any) => { loginData = d; });
|
||||
client.on('message', (m: any) => business.push(m));
|
||||
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'); },
|
||||
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();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
client.start();
|
||||
await t.flush();
|
||||
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]);
|
||||
|
||||
// 服务器先发握手包(原始串,应被忽略)
|
||||
t.serverPush('@toconconHELLO');
|
||||
await t.flush();
|
||||
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]);
|
||||
|
||||
// 客户端应已发出 player_login
|
||||
assert.equal(JSON.parse(t.sent[0]).rpc, 'player_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]);
|
||||
});
|
||||
|
||||
// 服务器回 login 响应(单层,带房间恢复 + 对局)
|
||||
t.serverPush(JSON.stringify({
|
||||
route: 'agent', rpc: 'player_login',
|
||||
data: { state: 0, playerid: 42, bean: 500, roomcode: 'ROOM1', isbattle: 1, deskinfo: { round: 3 } },
|
||||
}));
|
||||
await t.flush();
|
||||
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]);
|
||||
});
|
||||
}
|
||||
|
||||
assert.ok(loginData);
|
||||
const parsed = parseLoginResponse(loginData);
|
||||
assert.equal(parsed.ok, true);
|
||||
assert.equal(parsed.playerid, 42);
|
||||
assert.equal(parsed.inRoom, true);
|
||||
assert.equal(parsed.hasBattle, true);
|
||||
assert.deepEqual(parsed.deskinfo, { round: 3 });
|
||||
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();
|
||||
|
||||
// 心跳包(单层,忽略,不进 business)
|
||||
t.serverPush(JSON.stringify({ com: '@serverheartbeat' }));
|
||||
// 正常业务推送(单层,门控已清,放行)
|
||||
t.serverPush(JSON.stringify({ route: 'room', rpc: 'other_join_room', data: { seat: 2 } }));
|
||||
await t.flush();
|
||||
|
||||
assert.equal(business.length, 1);
|
||||
assert.equal(business[0].rpc, 'other_join_room');
|
||||
assert.equal(business[0].data.seat, 2);
|
||||
|
||||
client.stop();
|
||||
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]);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user