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,12 +1,14 @@
import { afterEach, test } from 'node:test';
import assert from 'node:assert/strict';
import { existsSync } from 'node:fs';
import { existsSync, readdirSync } from 'node:fs';
import { spawnSync } from 'node:child_process';
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import {
findCompatibilityDependency,
LEGACY_RUNTIME,
scanImportBoundaries,
} from '../../scripts/lib/import-boundaries.mjs';
@@ -37,14 +39,6 @@ function scan(root, tsconfigPath) {
}
const frameworkRoot = fileURLToPath(new URL('../../YouleNexus/assets/framework/', import.meta.url));
const modernRuntimeEntries = [
'net/wire-client.ts',
'platform/runtime-session.ts',
'protocol/platform-handlers.ts',
'protocol/router.ts',
// Task 10 creates this entry; the same assertion starts covering it as soon as it exists.
'platform/runtime.ts',
];
const legacyRuntimeFiles = new Set([
'net/net-client.ts',
'platform/session.ts',
@@ -67,11 +61,14 @@ function scanCompatibilityFixture(root, entry = 'framework/protocol/router.ts',
});
}
test('every existing modern runtime entry is transitively isolated from the legacy compatibility island', () => {
const entries = modernRuntimeEntries
.map((file) => resolve(frameworkRoot, file))
.filter((file) => existsSync(file));
assert.ok(entries.some((file) => file.endsWith(`${join('protocol', 'router.ts')}`)));
test('every modern framework file is transitively isolated from the exact legacy compatibility island', () => {
const files = readdirSync(frameworkRoot, { recursive: true })
.filter(file => file.endsWith('.ts'))
.map(file => resolve(frameworkRoot, file));
assert.deepEqual(new Set(files.filter(file => LEGACY_RUNTIME.test(file))), legacyRuntimeFiles);
const entries = files.filter(file => !legacyRuntimeFiles.has(file));
assert.ok(existsSync(resolve(frameworkRoot, 'platform/runtime.ts')));
assert.ok(entries.includes(resolve(frameworkRoot, 'sdk/index.ts')));
for (const entry of entries) {
const dependency = findCompatibilityDependency({
@@ -88,6 +85,22 @@ test('every existing modern runtime entry is transitively isolated from the lega
}
});
test('obsolete SDK identifier inventory stays within explicit negative tests and one legacy comment', () => {
const result = spawnSync('rg', [
'-l', '--glob', '*.ts', '--glob', '*.mjs',
'\\b(GameContext|IGameModule|ActiveGame)\\b',
'YouleNexus/assets/framework', 'framework-tests',
], { cwd: fileURLToPath(new URL('../../', import.meta.url)), encoding: 'utf8' });
assert.ifError(result.error);
assert.ok(result.status === 0 || result.status === 1, result.stderr);
const allowlist = new Set([
'framework-tests/architecture/import-boundaries.test.mjs',
'YouleNexus/assets/framework/protocol/room-handlers.ts',
]);
const matches = result.stdout.trim().split(/\r?\n/).filter(Boolean).map(file => file.replaceAll('\\', '/'));
assert.deepEqual(matches.filter(file => !allowlist.has(file)), []);
});
test('compatibility graph catches static imports, re-exports, and import types through barrels', async () => {
const cases = [
"import '../platform/session.ts'\n",
@@ -427,7 +440,7 @@ test('scanner rejects a CommonJS Cocos import in nested sdk code', async () => {
assert.match(violations[0].message, /sdk.*Cocos/);
});
test('sdk migration barrel exception permits only its four retained legacy dependencies', async () => {
test('sdk public barrel has no exemptions for any former migration dependency', async () => {
const root = await createFixtureRoot();
for (const file of [
'framework/core/events.ts',
@@ -446,10 +459,34 @@ test('sdk migration barrel exception permits only its four retained legacy depen
"import type { Value as Constants } from '../core/constants.ts'; void (0 as unknown as Constants);",
].join('\n'));
const violations = scan(root);
assert.equal(violations.length, 5);
for (const violation of violations) assert.match(violation.message, /sdk.*framework implementation/);
});
test('former active-game file has no exemption from obsolete SDK name checks', async () => {
const root = await createFixtureRoot();
await writeFixture(root, 'framework/protocol/active-game.ts', "import type { IGameModule } from '../sdk/index.ts'\n");
const violations = scan(root);
assert.equal(violations.length, 1);
assert.equal(violations[0].specifier, '../core/constants.ts');
assert.match(violations[0].message, /sdk.*framework implementation/);
assert.match(violations[0].message, /IGameModule.*migration-only/);
});
test('obsolete SDK names are rejected in destructured CommonJS and dynamic imports and named re-exports', async () => {
for (const source of [
"const { GameContext: Context } = require('../sdk/index.ts');",
"const { IGameModule } = require(`../sdk/index.ts`);",
"const { GameContext } = await import('../sdk/index.ts');",
"const Context = require('../sdk/index.ts').GameContext;",
"export type { IGameModule } from '../sdk/index.ts';",
"type Context = import('../sdk/index.ts').GameContext;",
]) {
const root = await createFixtureRoot();
await writeFixture(root, 'framework/application/bad.ts', source);
const violations = scan(root);
assert.equal(violations.length, 1, source);
assert.match(violations[0].message, /migration-only/, source);
}
});
test('scanner permits sdk-internal relatives and ordinary external pure dependencies', async () => {
@@ -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]);
});
@@ -0,0 +1,277 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { test } from 'node:test';
import { HttpConfigFetcher } from '../../YouleNexus/assets/framework/config/remote-config-fetcher.ts';
import { resolveRuntimeConfig } from '../../YouleNexus/assets/framework/config/runtime-config.ts';
import type { InboundMessage } from '../../YouleNexus/assets/framework/core/types/envelope.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 { GameEntry, GameHost, GameModule } from '../../YouleNexus/assets/framework/sdk/index.ts';
import { FakeTransport } from '../helpers/fake-transport.ts';
// Independent wire expectations: do not build these with production envelope helpers.
const LOGIN = {
app: 'youle', route: 'agent', rpc: 'player_login', data: {
agentid: 'veRa0qrBf', gameid: 'J9r4022t80pxlb01zOK76DN1WD4QewFl',
openid: 'o1', nickname: '测试号', avatar: 'http://a', sex: 0,
province: '江西', city: '南昌', unionid: 'test_unionid', version: 1,
channelid: 'FtJf073aa', marketid: 4, ip: '127.0.0.1',
location: { latitude: 1, longitude: 2 }, machineid: 'machine-1',
machineroom: 'room-install-1',
},
};
const JOIN = {
app: 'youle', route: 'agent', rpc: 'self_join_room', data: {
agentid: 'veRa0qrBf', playerid: 430511,
gameid: 'J9r4022t80pxlb01zOK76DN1WD4QewFl', roomcode: '100001',
location: { latitude: 1, longitude: 2 }, ip: '127.0.0.1',
},
};
const SWITCH = {
app: 'youle', route: 'room', rpc: 'connect_roomserver',
data: { roomserver: 'room.example:3089', token: 'original-token', roomcode: '100001' },
};
const PREPARE = {
app: 'youle', route: 'room', rpc: 'player_prepare', data: {
agentid: 'veRa0qrBf', playerid: 430511,
gameid: 'J9r4022t80pxlb01zOK76DN1WD4QewFl', roomcode: '100001',
},
};
class ReplayClock implements Clock {
private now = 0;
private next = 0;
readonly timers = new Map<number, { at: number; fn: () => void }>();
setTimeout(fn: () => void, ms: number): unknown {
const handle = ++this.next;
this.timers.set(handle, { at: this.now + ms, fn });
return handle;
}
clearTimeout(handle: unknown): void { this.timers.delete(handle as number); }
advance(ms: number): void {
const end = this.now + ms;
for (;;) {
const next = [...this.timers].sort((a, b) => a[1].at - b[1].at)[0];
if (!next || next[1].at > end) break;
this.now = next[1].at;
this.timers.delete(next[0]);
next[1].fn();
}
this.now = end;
}
}
function fixture(name: string): InboundMessage & { data: Record<string, any> } {
return JSON.parse(readFileSync(new URL(`../fixtures/contracts/${name}.json`, import.meta.url), 'utf8'));
}
test('real modern vertical replay preserves wire contracts, room ordering and terminal kick', async (t) => {
const clock = new ReplayClock();
const transports: FakeTransport[] = [];
const nativeCalls: string[] = [];
const configRequests: Array<{ url: string; init: RequestInit | undefined }> = [];
const scenes: string[] = [];
const fatals: Error[] = [];
const observations: string[] = [];
const attached: GameHost[] = [];
const restored: unknown[] = [];
const disposed: GameHost[] = [];
const routed: InboundMessage[] = [];
const decoded: 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);
});
const entry: GameEntry = {
key: 'fixture', gameId: 'J9r4022t80pxlb01zOK76DN1WD4QewFl', route: 'fixture-game',
resolveSeatCount: () => 4,
createModule(): GameModule {
let host: GameHost | undefined;
return {
attach(value) {
host = value;
attached.push(value);
observations.push(`attach:${value.getSnapshot().room.roomcode}`);
value.subscribe(snapshot => {
observations.push(`snapshot:${snapshot.seats.find(seat => seat.seat === 1)?.ready}`);
});
},
handlePlatformEvent(event) {
if (event.type === 'room.player-ready') {
observations.push(`ready:${host!.getSnapshot().seats.find(seat => seat.seat === event.seat)?.ready}`);
}
},
handleGameMessage(message) { observations.push(`game:${message.rpc}`); },
restore(value) {
assert.equal(host!.getSnapshot().room.roomcode, '100001');
assert.equal(scenes[scenes.length - 1], 'room');
restored.push(value);
},
dispose() {
// GameEntry validation also constructs/disposes a module without attaching.
if (host) disposed.push(host);
},
};
},
};
const runtime = new PlatformRuntime({
gameEntry: entry,
resolveRuntimeConfig: () => resolveRuntimeConfig({
mode: 'release', hostKind: 'native-settings', search: '',
win: { settings: {
getothername(name: string) {
nativeCalls.push(`getothername:${name}`);
if (name === 'agent') return 'veRa0qrBf';
assert.equal(name, 'gameconfig');
return 'config.example-config';
},
getchannelName() { nativeCalls.push('getchannelName'); return 'FtJf073aa'; },
getmarketname() { nativeCalls.push('getmarketname'); return 4; },
} },
cacheBust: () => 'fixed-cache-bust',
fetcher: new HttpConfigFetcher(async (url, init) => {
configRequests.push({ url: String(url), init });
return new Response(JSON.stringify({
urlserver: 'wrong-root.example:1',
data: { urlserver: ['agent.example:3088', 'backup.example:3088'] },
}), { status: 200 });
}),
}),
createWireClient(config) {
assert.deepEqual(config.servers, ['ws://agent.example:3088', 'ws://backup.example:3088']);
const wire = new WireClient({ servers: config.servers, clock, transportFactory() {
const transport = new FakeTransport();
transports.push(transport);
return transport;
} });
wire.subscribe(event => { if (event.type === 'message') decoded.push(event.message); });
return wire;
},
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: () => ({
ip: '127.0.0.1', location: { latitude: 1, longitude: 2 },
machineid: 'machine-1', machineroom: 'room-install-1',
deviceLogin: { enabled: false }, loginPlayerId: { enabled: false },
}),
});
t.after(() => runtime.stop());
const expectedFrames: unknown[] = [];
function verifyFrames(): void {
assert.deepEqual(transports.flatMap(transport => transport.sent.map(frame => JSON.parse(frame))), expectedFrames);
assert.deepEqual(fatals, []);
}
async function push(transport: FakeTransport, message: InboundMessage): Promise<InboundMessage> {
const before = routed.length;
transport.serverPush(JSON.stringify(message));
await transport.flush();
assert.equal(routed.length, before + 1, `${message.rpc} must enter Router once`);
assert.equal(routed[routed.length - 1], decoded[decoded.length - 1], 'Router receives the decoded object itself');
verifyFrames();
return routed[routed.length - 1]!;
}
await runtime.start();
assert.deepEqual(nativeCalls, ['getothername:agent', 'getchannelName', 'getmarketname', 'getothername:gameconfig']);
assert.deepEqual(configRequests, [{
url: 'http://config.example/config.txt?fixed-cache-bust', init: { method: 'POST', body: '' },
}]);
const agent = transports[0]!;
assert.equal(agent.url, 'ws://agent.example:3088');
await agent.flush();
verifyFrames();
assert.deepEqual(scenes, ['loading', 'login']);
assert.equal(runtime.ready, true);
assert.equal(runtime.state.app.phase, 'connected');
agent.serverPush('@toconconHELLO');
agent.serverPush(JSON.stringify({ com: '@serverheartbeat' }));
await agent.flush();
assert.equal(routed.length, 0);
runtime.login({
openid: 'o1', nickname: '测试号', avatar: 'http://a', sex: 0,
province: '江西', city: '南昌', unionid: 'test_unionid',
});
expectedFrames.push(LOGIN);
verifyFrames();
await push(agent, fixture('player-login-success'));
assert.equal(scenes[scenes.length - 1], 'lobby');
assert.equal(runtime.state.room.kind, 'outside');
runtime.joinRoom({ roomcode: '100001', location: { latitude: 1, longitude: 2 }, ip: '127.0.0.1' });
expectedFrames.push(JOIN);
verifyFrames();
await push(agent, SWITCH);
assert.equal(agent.closeCalls, 1);
clock.advance(9999);
assert.equal(transports.length, 1);
clock.advance(1);
const room = transports[1]!;
assert.equal(room.url, 'ws://room.example:3089');
assert.deepEqual(room.sent, []);
expectedFrames.push(SWITCH);
await room.flush();
verifyFrames();
const join = fixture('self-join-room');
delete join.data.deskinfo;
join.data.deskwar = 0;
join.data.players[1].isprepare = 0;
await push(room, join);
assert.equal(attached.length, 1);
assert.equal(runtime.state.room.kind, 'inside');
assert.equal(scenes[scenes.length - 1], 'room');
assert.equal(attached[0]!.getSnapshot().connection.phase, 'logged-in');
assert.deepEqual(restored, []);
assert.deepEqual(observations, ['attach:100001', 'snapshot:false']);
runtime.prepare();
expectedFrames.push(PREPARE);
verifyFrames();
await push(room, { route: 'room', rpc: 'player_prepare', data: { seat: 1 } });
assert.deepEqual(observations.slice(-2), ['snapshot:true', 'ready:true']);
await push(room, { route: 'fixture-game', rpc: 'deal', data: { cards: [7, 8] } });
assert.equal(observations[observations.length - 1], 'game:deal');
const priorRoom = runtime.state.room;
room.serverClose();
await room.flush();
assert.equal(scenes[scenes.length - 1], 'reconnect');
assert.equal(attached[0]!.getSnapshot().connection.phase, 'reconnecting');
clock.advance(9999);
assert.equal(transports.length, 2);
clock.advance(1);
const reconnected = transports[2]!;
assert.equal(reconnected.url, 'ws://room.example:3089');
expectedFrames.push(LOGIN);
await reconnected.flush();
verifyFrames();
const restoredEnvelope = await push(reconnected, fixture('player-login-room'));
assert.notEqual(runtime.state.room, priorRoom);
assert.equal(attached.length, 2);
assert.notEqual(attached[0], attached[1]);
assert.deepEqual(disposed, [attached[0]]);
assert.throws(() => attached[0]!.getSnapshot(), /invalid|inactive|disposed/i);
assert.equal(restored.length, 1);
assert.equal(restored[0], (restoredEnvelope.data as Record<string, unknown>).deskinfo);
assert.equal(Object.prototype.hasOwnProperty.call(runtime.state.room, 'deskinfo'), false);
assert.equal(attached[1]!.getSnapshot().connection.phase, 'logged-in');
await push(reconnected, { route: 'agent', rpc: 'kick_server', data: { msg: 'elsewhere' } });
assert.equal(scenes[scenes.length - 1], 'kicked');
assert.equal(runtime.state.app.phase, 'kicked');
assert.deepEqual(disposed, attached);
assert.equal(reconnected.closeCalls, 1);
assert.throws(() => attached[1]!.getSnapshot(), /invalid|inactive|disposed/i);
assert.equal(clock.timers.size, 0);
clock.advance(60000);
assert.equal(transports.length, 3);
assert.deepEqual(routed, decoded);
verifyFrames();
});
@@ -977,9 +977,12 @@ test('slow and reconnecting show reconnect without clearing or replacing room st
const callsBefore = setup.scene.calls.length;
setup.wire.emit({ type: 'slow' });
assert.equal(setup.runtime.state.app.phase, 'slow');
setup.wire.emit({ type: 'reconnecting', server: 'ws://agent' });
assert.equal(setup.runtime.state, roomState);
assert.equal(setup.runtime.state.app.phase, 'reconnecting');
assert.equal(setup.runtime.state.room, roomState.room);
assert.equal(setup.runtime.state.players, roomState.players);
assert.equal(setup.runtime.state.room.kind, 'inside');
assert.deepEqual(
setup.scene.calls.slice(callsBefore).map((call) => call.name),
@@ -1,132 +0,0 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { EventBus } from '../../YouleNexus/assets/framework/core/events.ts';
import { toView, fromView } from '../../YouleNexus/assets/framework/core/seat.ts';
import { PlatformSession } from '../../YouleNexus/assets/framework/platform/session.ts';
import type { NetClientEvents } from '../../YouleNexus/assets/framework/net/net-client.ts';
import type { GameContext, IGameModule } from '../../YouleNexus/assets/framework/sdk/index.ts';
/**
* 真实子游戏端到端模拟:创建 PlatformSession + 注册 Mock 子游戏 + 注入 GameContext。
* 验证契约边界(IGameModule 钩子触发、GameContext 只读、send 受限)。
*/
function makeContext(): {
ctx: GameContext;
sent: Array<{ route: string; rpc: string; data: any }>;
bus: EventBus<NetClientEvents>;
session: PlatformSession;
} {
const bus = new EventBus<NetClientEvents>();
const session = new PlatformSession(bus);
// 受限的 send:只记录,不动 NetClient transport(架构零耦合规则)
const sent: Array<{ route: string; rpc: string; data: any }> = [];
const net = {
send(route: string, rpc: string, data: unknown) { sent.push({ route, rpc, data }); },
};
const ctx: GameContext = {
net,
room: session.room,
player: session.player,
app: session.app,
seat: { toView, fromView },
events: new EventBus(),
};
return { ctx, sent, bus, session };
}
/** Mock 子游戏:捕获所有钩子调用。 */
class MockGame implements IGameModule {
readonly route = 'mock-game';
events: string[] = [];
onEnter(_ctx: GameContext): void { this.events.push('onEnter'); }
onExit(): void { this.events.push('onExit'); }
onReceive(rpc: string, _data: unknown): void { this.events.push(`onReceive:${rpc}`); }
onReconnect(deskinfo: unknown): void { this.events.push('onReconnect'); void deskinfo; }
serialize(): unknown { return { mock: 'snapshot' }; }
// 默认空钩子测试
onPlayerJoin(seat: number): void { this.events.push(`onPlayerJoin:${seat}`); }
onPlayerLeave(seat: number): void { this.events.push(`onPlayerLeave:${seat}`); }
onReady(seat: number): void { this.events.push(`onReady:${seat}`); }
onDissolve(): void { this.events.push('onDissolve'); }
onOffline(seat: number): void { this.events.push(`onOffline:${seat}`); }
}
test('IGameModule 契约: 注册后 onEnter 触发', () => {
const { ctx } = makeContext();
const game = new MockGame();
game.onEnter(ctx);
assert.deepEqual(game.events, ['onEnter']);
});
test('IGameModule 契约: onReceive 接收 rpc + data', () => {
const { ctx } = makeContext();
const game = new MockGame();
game.onEnter(ctx);
game.onReceive('game_action', { x: 1 });
assert.deepEqual(game.events, ['onEnter', 'onReceive:game_action']);
});
test('IGameModule 契约: 平台钩子默认空实现不抛错(可选)', () => {
// 一个只实现必需钩子的子游戏
const minimal: IGameModule = {
route: 'minimal',
onEnter() { /* noop */ },
onExit() { /* noop */ },
onReceive(_rpc, _data) { /* noop */ },
onReconnect(_d) { /* noop */ },
// 钩子全部可选
};
// 编译通过 + 不抛错即通过
minimal.onPlayerJoin?.(1);
minimal.onPlayerLeave?.(2);
minimal.onReady?.(3);
minimal.onDissolve?.();
minimal.onOffline?.(4);
});
test('GameContext: 只暴露 ReadonlyPlayerStore 接口,不暴露 applyLogin', () => {
const { ctx } = makeContext();
// type 层面:ctx.player 是 ReadonlyPlayerStore 接口,没有 applyLogin
// 这里用类型断言模拟「如果按 ReadonlyPlayerStore 类型使用,applyLogin 应不存在」
type ReadonlyView = { readonly state: { readonly value: { playerid: number } } };
const view = ctx.player as unknown as ReadonlyView;
assert.equal(view.state.value.playerid, 0);
// 通过 keyof 验证:ReadonlyPlayerStore 接口暴露的方法只有 state getter
type ExposedKeys = keyof typeof ctx.player;
assert.ok('state' in ctx.player, 'state 是公开字段');
});
test('GameContext.net.send: 受限接口,不暴露 transport / ws_tcp / start', () => {
const { ctx, sent } = makeContext();
ctx.net.send('agent', 'join_table', { roomcode: 'R1' });
assert.deepEqual(sent, [{ route: 'agent', rpc: 'join_table', data: { roomcode: 'R1' } }]);
// 不应有 transport / ws_tcp / start
assert.equal((ctx.net as any).transport, undefined);
assert.equal((ctx.net as any).start, undefined);
});
test('GameContext.seat.toView: 替代旧 ChangeToStatus', () => {
const { ctx } = makeContext();
// 旧 ChangeToStatus 语义(已存在 core/seat.ts): 自己=0,其余环形顺延
assert.equal(ctx.seat.toView(0, 0, 4), 0); // 自己=自己
assert.equal(ctx.seat.toView(0, 1, 4), 1); // 右手位
assert.equal(ctx.seat.toView(0, 3, 4), 3); // 左手位
// 跨玩家视角:玩家 2 看玩家 0,2 应当是 2(view seat),0 应当是 -2 mod 4 = 2(实际为反向)
assert.equal(ctx.seat.fromView(2, 0, 4), 2); // 验证 fromView 反运算
});
test('IGameModule.route 必须等于自己的 game route(框架 Router 据此分发)', () => {
const game = new MockGame();
assert.equal(game.route, 'mock-game');
});
test('serialize 返回对局快照(断线重连用)', () => {
const game = new MockGame();
assert.deepEqual(game.serialize(), { mock: 'snapshot' });
});