Files
youle_cocos/cocoscreator_projects/framework-tests/integration/platform-vertical-slice.test.ts
T

278 lines
12 KiB
TypeScript

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();
});