feat(platform): add single-entry platform runtime

This commit is contained in:
2026-09-05 09:13:00 +08:00
parent 4cc83ea713
commit 3a9c5d6fdb
6 changed files with 1221 additions and 0 deletions
@@ -220,6 +220,37 @@ test('compatibility graph applies the same isolation to a future platform/runtim
assert.equal(found?.path.length, 3);
});
test('platform/runtime.ts cannot import any former startup owner directly', async () => {
const cases = [
{ file: 'framework/platform/startup.ts', specifier: './startup.ts' },
{ file: 'framework/net/net-client.ts', specifier: '../net/net-client.ts' },
{ file: 'framework/platform/session.ts', specifier: './session.ts' },
{ file: 'framework/platform/room-rpc-bus.ts', specifier: './room-rpc-bus.ts' },
];
for (const item of cases) {
const root = await createFixtureRoot();
await writeFixture(root, item.file, 'export const legacy = true\n');
await writeFixture(
root,
'framework/platform/runtime.ts',
`import '${item.specifier}'\n`,
);
const found = findCompatibilityDependency({
entry: join(root, 'framework/platform/runtime.ts'),
forbiddenFiles: [join(root, item.file)],
traversalRoot: join(root, 'framework'),
});
assert.equal(found?.kind, 'forbidden', item.file);
assert.deepEqual(
found?.path.map((file) => file.replaceAll('\\', '/').split('/framework/')[1]),
['platform/runtime.ts', item.file.slice('framework/'.length)],
item.file,
);
}
});
test('scanner rejects sdk contracts importing a framework platform path', async () => {
const root = await createFixtureRoot();
await writeFixture(root, 'framework/sdk/contracts/bad.ts', "import '../platform/session.ts'\n");
@@ -0,0 +1,36 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import {
ReadyGate,
type ReadyGateName,
} from '../../YouleNexus/assets/framework/platform/ready-gate.ts';
const GATES = [
'resources',
'config',
'socket',
'minimum-display',
] as const satisfies readonly ReadyGateName[];
for (let subset = 0; subset < 16; subset += 1) {
test(`ReadyGate is ${subset === 15 ? '' : 'not '}ready for subset ${subset.toString(2).padStart(4, '0')}`, () => {
const gate = new ReadyGate();
GATES.forEach((name, index) => {
if ((subset & (1 << index)) !== 0) gate.mark(name);
});
assert.equal(gate.ready, subset === 15);
});
}
test('ReadyGate duplicate marks are idempotent', () => {
const gate = new ReadyGate();
for (const name of GATES) {
gate.mark(name);
gate.mark(name);
}
assert.equal(gate.ready, true);
});
@@ -0,0 +1,615 @@
import { readFileSync } from 'node:fs';
import assert from 'node:assert/strict';
import { test } from 'node:test';
import type { RuntimeConfig } from '../../YouleNexus/assets/framework/config/runtime-config.ts';
import type { OutboundEnvelope } from '../../YouleNexus/assets/framework/core/types/envelope.ts';
import type { Clock } from '../../YouleNexus/assets/framework/net/heartbeat.ts';
import type { WireEvent } from '../../YouleNexus/assets/framework/net/wire-client.ts';
import type {
GameEntry,
GameHost,
GameModule,
PlatformToGameEvent,
} from '../../YouleNexus/assets/framework/sdk/contracts/index.ts';
import {
PlatformRuntime,
type PlatformWireClient,
} from '../../YouleNexus/assets/framework/platform/runtime.ts';
import type { ScenePort } from '../../YouleNexus/assets/framework/platform/scene-port.ts';
function fixture(name: string): Record<string, unknown> {
return JSON.parse(
readFileSync(new URL(`../fixtures/contracts/${name}`, import.meta.url), 'utf8'),
).data;
}
const ACCOUNT = Object.freeze({
openid: 'o1',
nickname: '测试号',
avatar: 'http://a',
sex: 0,
province: '江西',
city: '南昌',
unionid: 'test_unionid',
});
const DEVICE = Object.freeze({
ip: '127.0.0.1',
location: Object.freeze({ latitude: 1, longitude: 2 }),
machineid: 'machine-1',
machineroom: 'room-install-1',
deviceLogin: Object.freeze({ enabled: false as const }),
loginPlayerId: Object.freeze({ enabled: false as const }),
});
function runtimeConfig(gameid: string | number = '41'): RuntimeConfig {
return Object.freeze({
mode: 'debug',
isDebugger: true,
identity: Object.freeze({
agentid: 'veRa0qrBf',
channelid: 'FtJf073aa',
gameid,
marketid: 4,
version: 10000,
}),
servers: Object.freeze(['ws://agent']),
source: 'direct',
gameserver: null,
rawConfig: null,
}) as RuntimeConfig;
}
class ManualClock implements Clock {
private nextHandle = 1;
readonly timers = new Map<number, { readonly fn: () => void; readonly ms: number }>();
setTimeout(fn: () => void, ms: number): unknown {
const handle = this.nextHandle;
this.nextHandle += 1;
this.timers.set(handle, { fn, ms });
return handle;
}
clearTimeout(handle: unknown): void {
this.timers.delete(handle as number);
}
runOnlyTimer(): void {
assert.equal(this.timers.size, 1);
const [handle, timer] = [...this.timers.entries()][0]!;
this.timers.delete(handle);
timer.fn();
}
}
class RecordingWireClient implements PlatformWireClient {
readonly sent: OutboundEnvelope[] = [];
readonly switchCalls: string[] = [];
readonly lifecycle: string[] = [];
reconnectCalls = 0;
stopCalls = 0;
onAction: (name: string) => void = () => {};
private readonly listeners = new Set<(event: WireEvent) => void>();
subscribe(listener: (event: WireEvent) => void): () => void {
this.lifecycle.push('subscribe');
this.onAction('subscribe');
this.listeners.add(listener);
return () => {
this.lifecycle.push('unsubscribe');
this.onAction('unsubscribe');
this.listeners.delete(listener);
};
}
start(): void {
this.lifecycle.push('start');
this.onAction('start');
}
send(envelope: OutboundEnvelope): void {
this.sent.push(envelope);
}
switchServer(target: string): void {
this.switchCalls.push(target);
}
reconnectCurrent(): void {
this.reconnectCalls += 1;
}
stop(): void {
this.stopCalls += 1;
this.lifecycle.push('stop');
this.onAction('stop');
}
emit(event: WireEvent): void {
for (const listener of [...this.listeners]) listener(event);
}
}
class RecordingScene implements ScenePort {
readonly calls: Array<{ readonly name: string; readonly value?: unknown }> = [];
onCall: (name: string, value?: unknown) => void = () => {};
private record(name: string, value?: unknown): void {
this.calls.push(value === undefined ? { name } : { name, value });
this.onCall(name, value);
}
showLoading(): void { this.record('loading'); }
showLogin(): void { this.record('login'); }
showLobby(): void { this.record('lobby'); }
showRoom(): void { this.record('room'); }
showReconnect(): void { this.record('reconnect'); }
showKicked(data: unknown): void { this.record('kicked', data); }
showFatal(error: Error): void { this.record('fatal', error); }
}
function makeGameEntry(overrides: Partial<GameEntry> = {}): GameEntry {
return {
key: 'fixture',
gameId: '41',
route: 'fixture-game',
resolveSeatCount: () => 4,
createModule: (): GameModule => ({
attach() {},
handlePlatformEvent() {},
handleGameMessage() {},
restore() {},
dispose() {},
}),
...overrides,
};
}
function makeRuntime(config: RuntimeConfig = runtimeConfig(), entry = makeGameEntry()) {
const wire = new RecordingWireClient();
const scene = new RecordingScene();
const clock = new ManualClock();
const factoryConfigs: RuntimeConfig[] = [];
const runtime = new PlatformRuntime({
gameEntry: entry,
resolveRuntimeConfig: async () => config,
createWireClient(resolved) {
factoryConfigs.push(resolved);
return wire;
},
scene,
loadResources: async () => {},
waitForMinimumDisplay: async () => {},
getLoginDeviceSnapshot: () => DEVICE,
clock,
});
return { runtime, wire, scene, clock, factoryConfigs, config };
}
function makeLoggingEntry(log: string[], restored: unknown[], attached: GameHost[]): GameEntry {
let recording = false;
const entry = makeGameEntry({
createModule: (): GameModule => {
let host: GameHost | null = null;
return {
attach(value) {
host = value;
if (!recording) return;
attached.push(value);
log.push(`attach:${value.getSnapshot().room.roomcode}`);
},
handlePlatformEvent(event: PlatformToGameEvent) {
if (recording) log.push(`event:${event.type}`);
},
handleGameMessage() {},
restore(deskinfo) {
if (!recording) return;
assert.ok(host);
restored.push(deskinfo);
log.push('restore');
},
dispose() {
if (!recording) return;
assert.ok(host);
host.getSnapshot();
log.push('dispose');
},
};
},
});
Object.defineProperty(entry, 'beginRecording', {
value: () => { recording = true; },
});
return entry;
}
function beginEntryRecording(entry: GameEntry): void {
(entry as GameEntry & { beginRecording(): void }).beginRecording();
}
test('start resolves config, subscribes before connect, and never logs in without an account', async () => {
const setup = makeRuntime();
await setup.runtime.start();
setup.wire.emit({ type: 'open', server: 'ws://agent' });
assert.equal(setup.factoryConfigs.length, 1);
assert.equal(setup.factoryConfigs[0], setup.config);
assert.deepEqual(setup.wire.lifecycle, ['subscribe', 'start']);
assert.deepEqual(setup.wire.sent, []);
assert.equal(setup.runtime.ready, true);
assert.deepEqual(setup.scene.calls.map((call) => call.name), ['loading', 'login']);
});
test('login sends one exact request and an ordinary reopen resends the same data with a four-second guard', async () => {
const setup = makeRuntime();
await setup.runtime.start();
setup.wire.emit({ type: 'open', server: 'ws://agent' });
setup.runtime.login(ACCOUNT);
assert.deepEqual(setup.wire.sent[0], {
app: 'youle',
route: 'agent',
rpc: 'player_login',
data: {
agentid: 'veRa0qrBf', gameid: '41', openid: 'o1', nickname: '测试号',
avatar: 'http://a', sex: 0, province: '江西', city: '南昌',
unionid: 'test_unionid', version: 10000, channelid: 'FtJf073aa',
marketid: 4, ip: '127.0.0.1', location: DEVICE.location,
machineid: 'machine-1', machineroom: 'room-install-1',
},
});
assert.deepEqual([...setup.clock.timers.values()].map((timer) => timer.ms), [4000]);
setup.wire.emit({
type: 'message',
message: { route: 'agent', rpc: 'player_login', data: fixture('player-login-success.json') },
});
assert.equal(setup.clock.timers.size, 0);
setup.wire.emit({ type: 'open', server: 'ws://agent' });
assert.equal(setup.wire.sent.length, 2);
assert.equal(setup.wire.sent[1]?.rpc, 'player_login');
assert.equal(setup.wire.sent[1]?.data, setup.wire.sent[0]?.data);
assert.deepEqual([...setup.clock.timers.values()].map((timer) => timer.ms), [4000]);
setup.clock.runOnlyTimer();
assert.equal(setup.wire.reconnectCalls, 1);
});
for (const mismatch of [
{ label: 'different value', configGameId: '42', entryGameId: '41' },
{ label: 'different type', configGameId: '41', entryGameId: 41 },
] as const) {
test(`gameid ${mismatch.label} fails fatally before WireClient creation`, async () => {
const setup = makeRuntime(
runtimeConfig(mismatch.configGameId),
makeGameEntry({ gameId: mismatch.entryGameId }),
);
await assert.rejects(() => setup.runtime.start(), /gameid/i);
await assert.rejects(() => setup.runtime.start(), /fatal/i);
assert.equal(setup.factoryConfigs.length, 0);
assert.deepEqual(setup.wire.lifecycle, []);
const fatalCalls = setup.scene.calls.filter((call) => call.name === 'fatal');
assert.equal(fatalCalls.length, 1);
assert.ok(fatalCalls[0]?.value instanceof Error);
});
}
for (const switchCase of [
{
label: 'room',
route: 'room',
rpc: 'connect_roomserver',
target: 'ws://room.example:3088',
data: { roomserver: 'ws://room.example:3088', epoch: 7, nested: { trace: 'r' } },
},
{
label: 'agent',
route: 'agent',
rpc: 'connect_agentserver',
target: 'wss://agent.example/socket',
data: {
agentserver: 'wss://agent.example/socket',
opt: 'free_room',
nested: { trace: 'a' },
},
},
] as const) {
test(`${switchCase.label} switch resends the exact switch envelope before returning to steady login`, async () => {
const setup = makeRuntime();
await setup.runtime.start();
setup.wire.emit({ type: 'open', server: 'ws://agent' });
setup.runtime.login(ACCOUNT);
const loginEnvelope = setup.wire.sent[0]!;
setup.wire.emit({
type: 'message',
message: { route: 'agent', rpc: 'player_login', data: fixture('player-login-success.json') },
});
setup.wire.sent.length = 0;
setup.wire.emit({
type: 'message',
message: { route: switchCase.route, rpc: switchCase.rpc, data: switchCase.data },
});
assert.deepEqual(setup.wire.switchCalls, [switchCase.target]);
assert.equal(setup.wire.sent.length, 0);
setup.wire.emit({ type: 'open', server: switchCase.target });
assert.equal(setup.wire.sent.length, 1);
assert.equal(setup.wire.sent[0]?.rpc, switchCase.rpc);
assert.deepEqual(setup.wire.sent[0], {
app: 'youle',
route: switchCase.route,
rpc: switchCase.rpc,
data: switchCase.data,
});
assert.equal(setup.wire.sent[0]?.data, switchCase.data);
assert.equal(setup.clock.timers.size, 0, 'switch resend must not start the login guard');
setup.wire.emit({ type: 'open', server: switchCase.target });
assert.equal(setup.wire.sent.length, 2);
assert.equal(setup.wire.sent[1]?.rpc, 'player_login');
assert.equal(setup.wire.sent[1]?.data, loginEnvelope.data);
assert.deepEqual([...setup.clock.timers.values()].map((timer) => timer.ms), [4000]);
});
}
test('successful login without a room shows the lobby', async () => {
const setup = makeRuntime();
await setup.runtime.start();
setup.wire.emit({ type: 'open', server: 'ws://agent' });
setup.runtime.login(ACCOUNT);
setup.wire.emit({
type: 'message',
message: { route: 'agent', rpc: 'player_login', data: fixture('player-login-success.json') },
});
assert.equal(setup.runtime.state.room.kind, 'outside');
assert.equal(setup.scene.calls[setup.scene.calls.length - 1]?.name, 'lobby');
});
test('room login commits and opens before showRoom, then restores the exact deskinfo', async () => {
const log: string[] = [];
const restored: unknown[] = [];
const attached: GameHost[] = [];
const entry = makeLoggingEntry(log, restored, attached);
const setup = makeRuntime(runtimeConfig(), entry);
setup.scene.onCall = (name) => { if (name === 'room') log.push('scene:room'); };
await setup.runtime.start();
beginEntryRecording(entry);
setup.wire.emit({ type: 'open', server: 'ws://agent' });
setup.runtime.login(ACCOUNT);
const roomLogin = fixture('player-login-room.json');
setup.wire.emit({
type: 'message',
message: { route: 'agent', rpc: 'player_login', data: roomLogin },
});
assert.equal(setup.runtime.state.room.kind, 'inside');
assert.deepEqual(log, [
'attach:100001',
'event:room.entered',
'scene:room',
'restore',
]);
assert.deepEqual(restored, [roomLogin.deskinfo]);
assert.equal(restored[0], roomLogin.deskinfo);
assert.equal(attached.length, 1);
});
for (const withDeskinfo of [false, true]) {
test(`self join opens and shows the room ${withDeskinfo ? 'before restoring deskinfo' : 'without restoring absent deskinfo'}`, async () => {
const log: string[] = [];
const restored: unknown[] = [];
const attached: GameHost[] = [];
const entry = makeLoggingEntry(log, restored, attached);
const setup = makeRuntime(runtimeConfig(), entry);
setup.scene.onCall = (name) => { if (name === 'room') log.push('scene:room'); };
await setup.runtime.start();
beginEntryRecording(entry);
setup.wire.emit({ type: 'open', server: 'ws://agent' });
setup.runtime.login(ACCOUNT);
setup.wire.emit({
type: 'message',
message: { route: 'agent', rpc: 'player_login', data: fixture('player-login-success.json') },
});
log.length = 0;
const join = fixture('self-join-room.json');
const deskinfo = join.deskinfo;
if (!withDeskinfo) delete join.deskinfo;
setup.wire.emit({
type: 'message',
message: { route: 'agent', rpc: 'self_join_room', data: join },
});
assert.deepEqual(log, withDeskinfo
? ['attach:100001', 'event:room.entered', 'scene:room', 'restore']
: ['attach:100001', 'event:room.entered', 'scene:room']);
assert.deepEqual(restored, withDeskinfo ? [deskinfo] : []);
if (withDeskinfo) assert.equal(restored[0], deskinfo);
});
}
test('slow and reconnecting show reconnect without clearing or replacing room state', async () => {
const setup = makeRuntime();
await setup.runtime.start();
setup.wire.emit({ type: 'open', server: 'ws://agent' });
setup.runtime.login(ACCOUNT);
setup.wire.emit({
type: 'message',
message: { route: 'agent', rpc: 'player_login', data: fixture('player-login-room.json') },
});
const roomState = setup.runtime.state;
const callsBefore = setup.scene.calls.length;
setup.wire.emit({ type: 'slow' });
setup.wire.emit({ type: 'reconnecting', server: 'ws://agent' });
assert.equal(setup.runtime.state, roomState);
assert.equal(setup.runtime.state.room.kind, 'inside');
assert.deepEqual(
setup.scene.calls.slice(callsBefore).map((call) => call.name),
['reconnect', 'reconnect'],
);
});
test('kick closes the game before showing kicked and stopping the wire', async () => {
const log: string[] = [];
const restored: unknown[] = [];
const attached: GameHost[] = [];
const entry = makeLoggingEntry(log, restored, attached);
const setup = makeRuntime(runtimeConfig(), entry);
setup.scene.onCall = (name) => { if (name === 'kicked') log.push('scene:kicked'); };
setup.wire.onAction = (name) => { if (name === 'stop') log.push('wire:stop'); };
await setup.runtime.start();
beginEntryRecording(entry);
setup.wire.emit({ type: 'open', server: 'ws://agent' });
setup.runtime.login(ACCOUNT);
setup.wire.emit({
type: 'message',
message: { route: 'agent', rpc: 'player_login', data: fixture('player-login-room.json') },
});
log.length = 0;
const kick = { msg: '检查到新版本', code: 1 };
setup.wire.emit({
type: 'message',
message: { route: 'agent', rpc: 'kick_server', data: kick },
});
assert.deepEqual(log, ['dispose', 'scene:kicked', 'wire:stop']);
assert.equal(setup.wire.stopCalls, 1);
assert.equal(setup.scene.calls[setup.scene.calls.length - 1]?.value, kick);
const sceneCalls = setup.scene.calls.length;
setup.wire.emit({ type: 'reconnecting', server: 'ws://agent' });
setup.wire.emit({ type: 'open', server: 'ws://agent' });
assert.equal(setup.scene.calls.length, sceneCalls, 'late wire events stay silent after kick');
assert.equal(setup.wire.sent.length, 1, 'late open never revives login after kick');
});
test('a game rpc named like a platform rpc cannot drive platform scenes', async () => {
const setup = makeRuntime();
await setup.runtime.start();
setup.wire.emit({ type: 'open', server: 'ws://agent' });
setup.runtime.login(ACCOUNT);
setup.wire.emit({
type: 'message',
message: { route: 'agent', rpc: 'player_login', data: fixture('player-login-room.json') },
});
const sceneCalls = setup.scene.calls.length;
setup.wire.emit({
type: 'message',
message: { route: 'fixture-game', rpc: 'self_exit_room', data: { reason: 'game-owned' } },
});
assert.equal(setup.runtime.state.room.kind, 'inside');
assert.equal(setup.scene.calls.length, sceneCalls);
});
test('joinRoom, prepare, and exitRoom delegate through the authoritative platform commands', async () => {
const setup = makeRuntime();
await setup.runtime.start();
setup.wire.emit({ type: 'open', server: 'ws://agent' });
setup.runtime.login(ACCOUNT);
setup.wire.emit({
type: 'message',
message: { route: 'agent', rpc: 'player_login', data: fixture('player-login-success.json') },
});
setup.wire.sent.length = 0;
const location = { latitude: 28.7, longitude: 115.8 };
setup.runtime.joinRoom({
roomcode: '100001',
location,
ip: '203.0.113.7',
vipMatch: 1,
match_id: 'match-9',
});
assert.deepEqual(setup.wire.sent, [{
app: 'youle',
route: 'agent',
rpc: 'self_join_room',
data: {
agentid: 'veRa0qrBf',
playerid: 430511,
gameid: '41',
roomcode: '100001',
location,
ip: '203.0.113.7',
vipMatch: 1,
match_id: 'match-9',
},
}]);
assert.equal((setup.wire.sent[0]!.data as { location: unknown }).location, location);
const joinedRoom = fixture('self-join-room.json');
delete joinedRoom.deskinfo;
joinedRoom.deskwar = 0;
setup.wire.emit({
type: 'message',
message: { route: 'agent', rpc: 'self_join_room', data: joinedRoom },
});
setup.wire.sent.length = 0;
setup.runtime.prepare();
setup.runtime.exitRoom();
assert.deepEqual(setup.wire.sent, [
{
app: 'youle',
route: 'room',
rpc: 'player_prepare',
data: {
agentid: 'veRa0qrBf', playerid: 430511, gameid: '41', roomcode: '100001',
},
},
{
app: 'youle',
route: 'room',
rpc: 'self_exit_room',
data: {
agentid: 'veRa0qrBf', playerid: 430511, gameid: '41', roomcode: '100001',
},
},
]);
});
test('stop unsubscribes, disposes while the host is live, invalidates it, then stops the wire', async () => {
const log: string[] = [];
const restored: unknown[] = [];
const attached: GameHost[] = [];
const entry = makeLoggingEntry(log, restored, attached);
const setup = makeRuntime(runtimeConfig(), entry);
setup.wire.onAction = (name) => {
if (name === 'unsubscribe' || name === 'stop') log.push(`wire:${name}`);
};
await setup.runtime.start();
beginEntryRecording(entry);
setup.wire.emit({ type: 'open', server: 'ws://agent' });
setup.runtime.login(ACCOUNT);
setup.wire.emit({
type: 'message',
message: { route: 'agent', rpc: 'player_login', data: fixture('player-login-room.json') },
});
const activeHost = attached[0]!;
log.length = 0;
setup.runtime.stop();
assert.deepEqual(log, ['wire:unsubscribe', 'dispose', 'wire:stop']);
assert.throws(() => activeHost.getSnapshot(), /disposed/i);
setup.runtime.stop();
assert.deepEqual(log, ['wire:unsubscribe', 'dispose', 'wire:stop']);
assert.equal(setup.wire.stopCalls, 1);
});