refactor(platform): complete decoupled vertical runtime
This commit is contained in:
@@ -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();
|
||||
});
|
||||
Reference in New Issue
Block a user