refactor(platform): complete decoupled vertical runtime

This commit is contained in:
2026-09-05 10:21:28 +08:00
parent d177c52c3f
commit 1e3a21b476
10 changed files with 611 additions and 330 deletions
@@ -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]);
});