Files
youle_cocos/cocoscreator_projects/framework-tests/platform/runtime.test.ts
T
joywayer 35216f75e7 feat: integrate room framework and isolated subgame bundles
Complete room UI and protocol integration, move game definitions and resources behind bundle entries, publish authoritative version XML, and document single-game builds. Include all current resource changes and experiment artifacts.
2026-09-09 03:13:18 +08:00

2627 lines
108 KiB
TypeScript

import {decodeFrame} from '../../YouleNexus/assets/framework/net/envelope-codec.ts';
import {GAME_DEFINITIONS} from '../fixtures/game-definitions.ts';
import { STARTUP_CONFIG as EDITABLE_STARTUP_CONFIG } from '../../YouleNexus/assets/framework/config/profiles.ts';
// 固定测试来源,允许开发者独立切换项目的 local/remote 开关。
const STARTUP_CONFIG = Object.freeze({...EDITABLE_STARTUP_CONFIG, configSource:'remote' as const});
import { remoteFixture } from '../fixtures/remote-config.ts';
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 { Router } from '../../YouleNexus/assets/framework/protocol/router.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';
import { PlatformStore } from '../../YouleNexus/assets/framework/platform/stores/platform-store.ts';
import { PlatformUiController } from '../../YouleNexus/assets/framework/presentation/platform-ui-controller.ts';
import type {LoginDeviceSnapshot} from '../../YouleNexus/assets/framework/protocol/contracts/login-contract.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({
startupConfig: STARTUP_CONFIG, mode: 'debug',
isDebugger: true,
identity: Object.freeze({
agentid: 'veRa0qrBf',
channelid: 'FtJf073aa',
gameid,
marketid: 4,
version: 10000,
}),
servers: Object.freeze(['ws://agent']),
source: 'remote',
gameserver: 'https://fixture/config',
...remoteFixture(),
}) as RuntimeConfig;
}
interface Deferred<T> {
readonly promise: Promise<T>;
resolve(value: T): void;
reject(error: unknown): void;
}
function deferred<T>(): Deferred<T> {
let resolvePromise!: (value: T) => void;
let rejectPromise!: (error: unknown) => void;
const promise = new Promise<T>((resolve, reject) => {
resolvePromise = resolve;
rejectPromise = reject;
});
return { promise, resolve: resolvePromise, reject: rejectPromise };
}
function rejectedValue(promise: Promise<unknown>): Promise<unknown> {
return promise.then(
() => { throw new Error('Expected promise to reject'); },
(error: unknown) => error,
);
}
function thrownValue(operation: () => void): unknown {
let didThrow = false;
let thrown: unknown;
try {
operation();
} catch (error) {
didThrow = true;
thrown = error;
}
assert.equal(didThrow, true, 'Expected operation to throw');
return thrown;
}
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 ControlledClearClock extends ManualClock {
private failureEnabled = false;
private failure: unknown;
onFailingClear: () => void = () => {};
enableClearFailure(error: unknown): void {
this.failure = error;
this.failureEnabled = true;
}
clearTimeout(handle: unknown): void {
super.clearTimeout(handle);
if (!this.failureEnabled) return;
this.onFailingClear();
throw this.failure;
}
}
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 SynchronousSubscribeWireClient implements PlatformWireClient {
readonly sent: OutboundEnvelope[] = [];
readonly switchCalls: string[] = [];
startCalls = 0;
stopCalls = 0;
unsubscribeCalls = 0;
private listener: ((event: WireEvent) => void) | null = null;
constructor(
private readonly subscribeEvent: WireEvent,
private readonly unsubscribeFailure?: { readonly value: unknown },
) {}
subscribe(listener: (event: WireEvent) => void): () => void {
this.listener = listener;
listener(this.subscribeEvent);
return () => {
this.unsubscribeCalls += 1;
this.listener = null;
if (this.unsubscribeFailure !== undefined) throw this.unsubscribeFailure.value;
};
}
start(): void {
this.startCalls += 1;
}
send(envelope: OutboundEnvelope): void {
this.sent.push(envelope);
}
switchServer(target: string): void {
this.switchCalls.push(target);
}
reconnectCurrent(): void {}
stop(): void {
this.stopCalls += 1;
}
emit(event: WireEvent): void {
this.listener?.(event);
}
get listenerAttached(): boolean {
return this.listener !== null;
}
}
class InvalidSubscribeWireClient extends RecordingWireClient {
constructor(private readonly invalidUnsubscribe: unknown) {
super();
}
override subscribe(_listener: (event: WireEvent) => void): () => void {
this.lifecycle.push('subscribe');
this.onAction('subscribe');
return this.invalidUnsubscribe as () => void;
}
}
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); }
showTips(message:string,time:number):void { this.record('tips',{message,time}); }
showServerDenial(outcome: unknown): void { this.record('denied', outcome); }
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(), device:LoginDeviceSnapshot=DEVICE) {
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 };
}
test('invite identity follows authorization for non-device login and server identity for device login',async()=>{
for(const deviceLogin of [{enabled:false},{enabled:true,telphone:'13800138000'}] as const){
const {runtime,wire}=makeRuntime(runtimeConfig(),makeGameEntry(),{...DEVICE,deviceLogin});
await runtime.start();wire.emit({type:'open',server:'ws://agent'});
runtime.login({...ACCOUNT,openid:'authorized-openid',unionid:12345});
const data=fixture('player-login-success.json');
if(!deviceLogin.enabled){delete data.openid;delete data.unionid;}
wire.emit({type:'message',message:{route:'agent',rpc:'player_login',data}});
runtime.getPlayerInvitecode();
assert.deepEqual(wire.sent[1].data,{agentid:'veRa0qrBf',playerid:430511,
openid:deviceLogin.enabled?'o1':'authorized-openid',unionid:deviceLogin.enabled?'test_unionid':12345});
wire.emit({type:'slow'});wire.emit({type:'open',server:'ws://agent'});
wire.emit({type:'message',message:{route:'agent',rpc:'player_login',data}});
runtime.getPlayerInvitecode();assert.deepEqual(wire.sent[wire.sent.length-1].data,wire.sent[1].data);
runtime.stop();
}
});
test('binding reply numeric and string states show server feedback without closing the wire',async()=>{
for(const state of [0,'0',1,'1',-1,'-1']){
const {runtime,wire,scene}=makeRuntime();await runtime.start();
wire.emit({type:'open',server:'ws://agent'});runtime.login(ACCOUNT);
wire.emit({type:'message',message:{route:'agent',rpc:'player_login',data:fixture('player-login-success.json')}});
runtime.bindInvitecode('00123');
const success=state===0||state==='0',error=success?'绑定成功':'邀请码无效';
wire.emit({type:'message',message:{route:'agent',rpc:'binding_invitecode',data:{state,error,...(success?{invitecode:'00123'}:{})}}});
assert.equal(wire.stopCalls,0,`state=${JSON.stringify(state)} must not disconnect`);
assert.equal(runtime.state.app.phase,'logged-in');
assert.equal(runtime.state.players.entities[430511].invitecode,success?'00123':null);
assert.ok(scene.calls.some(event=>event.name==='tips'&&JSON.stringify(event.value)===JSON.stringify({message:error,time:3000})));
assert.equal(scene.calls.some(event=>event.name==='fatal'),false);
runtime.getPlayerInvitecode();assert.equal(wire.sent[wire.sent.length-1].rpc,'get_player_invitecode');
runtime.stop();
}
});
test('VIP wire query response reaches canonical state and clears on login replacement',async()=>{
const {runtime,wire}=makeRuntime();await runtime.start();
wire.emit({type:'open',server:'ws://agent'});runtime.login(ACCOUNT);
const login=()=>wire.emit({type:'message',message:{route:'agent',rpc:'player_login',data:fixture('player-login-success.json')}});
login();runtime.getVipRooms(['123456']);
wire.emit({type:'message',message:{route:'agent',rpc:'getInfoByShortCode',data:{roomInfo:[{shortCode:123456,owner:'房主',playerList:[]}]}}});
assert.equal(runtime.state.vipRooms?.[0].shortCode,123456);
wire.emit({type:'message',message:{route:'agent',rpc:'getInfoByShortCode',data:{roomInfo:[]}}});
assert.deepEqual(runtime.state.vipRooms,[]);
login();assert.equal(runtime.state.vipRooms,undefined);runtime.stop();
});
test('task wire responses reach the store and claim does not invent reward balances',async()=>{
const {runtime,wire}=makeRuntime();await runtime.start();
wire.emit({type:'open',server:'ws://agent'});runtime.login(ACCOUNT);
wire.emit({type:'message',message:{route:'agent',rpc:'player_login',data:fixture('player-login-success.json')}});
runtime.getPlayerTasks();
wire.emit({type:'message',message:{route:'agent',rpc:'get_player_task',data:{tasks:[{taskid:7,title:'任务',award:'3',finish:'1',total:'1',state:'1'}]}}});
assert.equal(runtime.state.tasks?.[0].award,3);
const balance=runtime.state.players.entities[430511].roomcard;
runtime.claimTask(7);
wire.emit({type:'message',message:{route:'agent',rpc:'get_task_award',data:{taskid:'7',taskstate:0}}});
assert.equal(runtime.state.tasks?.[0].state,2);
assert.equal(runtime.state.players.entities[430511].taskstate,0);
assert.equal(runtime.state.players.entities[430511].roomcard,balance);
wire.emit({type:'message',message:{route:'agent',rpc:'update_roomcard',data:{roomcard:25}}});
assert.equal(runtime.state.players.entities[430511].roomcard,25);
wire.emit({type:'message',message:{route:'agent',rpc:'player_finish_task',data:{state:1}}});
assert.equal(runtime.state.players.entities[430511].taskstate,1);
wire.emit({type:'slow'});assert.throws(()=>runtime.getPlayerTasks(),/logged-in/);
runtime.stop();
});
test('UI observes canonical commits and unsubscribe is idempotent', async () => {
const { runtime, wire } = makeRuntime();
const seen: unknown[] = [];
const off = runtime.subscribeState(
state => { seen.push(state); },
error => { throw error; },
);
assert.equal(seen.length, 0);
await runtime.start();
wire.emit({ type: 'open', server: 'ws://agent' });
assert.equal(seen[seen.length - 1], runtime.state);
off();
off();
const count = seen.length;
runtime.login(ACCOUNT);
wire.emit({ type: 'message', message: {
route: 'agent', rpc: 'player_login', data: fixture('player-login-success.json'),
} });
assert.equal(seen.length, count);
runtime.stop();
assert.throws(() => runtime.subscribeState(() => {}, () => {}), /stopped/);
});
test('a failing UI observer reports its original error once while other observers continue', async () => {
const { runtime, wire } = makeRuntime();
const failure = { source: 'render' };
const reported: unknown[] = [];
let failingCalls = 0;
const healthyStates: unknown[] = [];
runtime.subscribeState(
() => {
failingCalls += 1;
throw failure;
},
error => { reported.push(error); },
);
runtime.subscribeState(
state => { healthyStates.push(state); },
error => { throw error; },
);
await runtime.start();
wire.emit({ type: 'open', server: 'ws://agent' });
assert.equal(failingCalls, 1);
assert.deepEqual(reported, [failure]);
assert.equal(reported[0], failure);
assert.equal(healthyStates[0], runtime.state);
runtime.login(ACCOUNT);
wire.emit({ type: 'message', message: {
route: 'agent', rpc: 'player_login', data: fixture('player-login-success.json'),
} });
assert.equal(failingCalls, 1);
assert.equal(healthyStates[healthyStates.length - 1], runtime.state);
runtime.stop();
});
test('an onError failure enters fatal with both UI error identities and cancels observers', async () => {
const { runtime, wire, scene } = makeRuntime();
const listenerFailure = { source: 'listener' };
const onErrorFailure = { source: 'onError' };
let laterObserverCalls = 0;
runtime.subscribeState(
() => { throw listenerFailure; },
() => { throw onErrorFailure; },
);
runtime.subscribeState(
() => { laterObserverCalls += 1; },
error => { throw error; },
);
await runtime.start();
wire.emit({ type: 'open', server: 'ws://agent' });
assert.equal(laterObserverCalls, 0);
assert.equal(wire.stopCalls, 1);
const fatal = scene.calls.find(call => call.name === 'fatal')?.value;
assert.ok(fatal instanceof Error);
const errors = (fatal as Error & { readonly errors: readonly unknown[] }).errors;
assert.equal(errors.length, 2);
assert.equal(errors[0], listenerFailure);
assert.equal(errors[1], onErrorFailure);
assert.throws(() => runtime.subscribeState(() => {}, () => {}), /fatal/);
});
for (const terminal of ['stop', 'fatal', 'kick'] as const) {
test(`${terminal} invalidates a late Store delivery and rejects new UI observers`, async () => {
const originalSubscribe = PlatformStore.prototype.subscribe;
let retainedListener: Parameters<PlatformStore['subscribe']>[0] | null = null;
PlatformStore.prototype.subscribe = function retainFirstListener(listener) {
retainedListener ??= listener;
return originalSubscribe.call(this, listener);
};
try {
const setup = makeRuntime();
let calls = 0;
setup.runtime.subscribeState(
() => { calls += 1; },
error => { throw error; },
);
await setup.runtime.start();
setup.wire.emit({ type: 'open', server: 'ws://agent' });
const callsBeforeTermination = calls;
setup.wire.onAction = (name) => {
if (name !== 'stop') return;
const listener = retainedListener;
assert.ok(listener);
listener(setup.runtime.state, setup.runtime.state);
};
if (terminal === 'stop') {
setup.runtime.stop();
} else if (terminal === 'fatal') {
setup.scene.onCall = (name) => {
if (name === 'reconnect') throw new Error('reconnect render failed');
};
setup.wire.emit({ type: 'slow' });
} else {
setup.runtime.login(ACCOUNT);
setup.wire.emit({ type: 'message', message: {
route: 'agent', rpc: 'kick_server', data: { code: 1, msg: 'terminal' },
} });
}
assert.equal(
calls,
callsBeforeTermination + (terminal === 'fatal' ? 1 : 0),
'observers must be inactive before terminal cleanup callbacks',
);
const callsAtTermination = calls;
const lateListener = retainedListener as Parameters<PlatformStore['subscribe']>[0] | null;
assert.ok(lateListener);
lateListener(setup.runtime.state, setup.runtime.state);
assert.equal(calls, callsAtTermination);
assert.throws(
() => setup.runtime.subscribeState(() => {}, () => {}),
new RegExp(terminal === 'kick' ? 'kicked' : terminal),
);
} finally {
PlatformStore.prototype.subscribe = originalSubscribe;
}
});
}
test('stop during a Store commit cancels later observers and prevents scene revival', async () => {
const setup = makeRuntime();
await setup.runtime.start();
setup.wire.emit({ type: 'open', server: 'ws://agent' });
let laterObserverCalls = 0;
let resubscribeFailure: unknown;
setup.runtime.subscribeState(
() => {
setup.runtime.stop();
resubscribeFailure = thrownValue(
() => setup.runtime.subscribeState(() => {}, () => {}),
);
},
error => { throw error; },
);
setup.runtime.subscribeState(
() => { laterObserverCalls += 1; },
error => { throw error; },
);
const sceneCallsBefore = setup.scene.calls.length;
setup.wire.emit({ type: 'slow' });
assert.equal(laterObserverCalls, 0);
assert.match(String(resubscribeFailure), /stopped/);
assert.equal(setup.scene.calls.length, sceneCallsBefore);
assert.equal(setup.wire.stopCalls, 1);
});
test('listener and onError failures after stopping escape the Store boundary without reviving fatal', async () => {
const setup = makeRuntime();
await setup.runtime.start();
setup.wire.emit({ type: 'open', server: 'ws://agent' });
const listenerFailure = { source: 'listener-after-stop' };
const onErrorFailure = { source: 'onError-after-stop' };
const reported: unknown[] = [];
setup.runtime.subscribeState(
() => {
setup.runtime.stop();
throw listenerFailure;
},
error => {
reported.push(error);
setup.runtime.stop();
throw onErrorFailure;
},
);
const sceneCallsBefore = setup.scene.calls.length;
const thrown = thrownValue(() => { setup.wire.emit({ type: 'slow' }); });
assert.deepEqual(reported, [listenerFailure]);
assert.ok(thrown instanceof Error);
const errors = (thrown as Error & { readonly errors: readonly unknown[] }).errors;
assert.equal(errors.length, 2);
assert.equal(errors[0], listenerFailure);
assert.equal(errors[1], onErrorFailure);
assert.equal(setup.scene.calls.length, sceneCallsBefore);
assert.equal(setup.scene.calls.filter(call => call.name === 'fatal').length, 0);
assert.equal(setup.wire.stopCalls, 1);
assert.throws(() => setup.runtime.subscribeState(() => {}, () => {}), /stopped/);
});
test('stopped observer errors survive a later active-room publish failure', async () => {
const setup = makeRuntime();
await setup.runtime.start();
setup.wire.emit({ type: 'open', server: 'ws://agent' });
setup.runtime.login(ACCOUNT);
const roomLogin = fixture('player-login-room.json');
(roomLogin.players as unknown[])[2] = {
playerid: 430512,
nickname: '同桌玩家',
avatar: 'http://b',
sex: 0,
bean: 10,
isprepare: 0,
onstate: 0,
};
setup.wire.emit({ type: 'message', message: {
route: 'agent', rpc: 'player_login', data: roomLogin,
} });
const listenerFailure = { source: 'room-listener-after-stop' };
const onErrorFailure = { source: 'room-onError-after-stop' };
setup.runtime.subscribeState(
() => {
setup.runtime.stop();
throw listenerFailure;
},
() => { throw onErrorFailure; },
);
const thrown = thrownValue(() => {
setup.wire.emit({ type: 'message', message: {
route: 'room', rpc: 'player_prepare', data: { seat: 2 },
} });
});
assert.ok(thrown instanceof Error);
const errors = (thrown as Error & { readonly errors: readonly unknown[] }).errors;
assert.equal(errors.length, 2);
const observerFailure = errors[0];
assert.ok(observerFailure instanceof Error);
const observerErrors = (
observerFailure as Error & { readonly errors: readonly unknown[] }
).errors;
assert.equal(observerErrors[0], listenerFailure);
assert.equal(observerErrors[1], onErrorFailure);
assert.match(String(errors[1]), /disposed game session/i);
assert.equal(setup.scene.calls.filter(call => call.name === 'fatal').length, 0);
assert.throws(() => setup.runtime.subscribeState(() => {}, () => {}), /stopped/);
});
test('UI observers receive each committed snapshot even when a listener commits reentrantly', async () => {
const setup = makeRuntime();
const delivered: unknown[] = [];
let firstCommit: unknown;
let latestDuringFirstCommit: unknown;
let reentered = false;
setup.runtime.subscribeState(
state => {
if (reentered) return;
reentered = true;
firstCommit = state;
setup.wire.emit({ type: 'slow' });
latestDuringFirstCommit = setup.runtime.state;
},
error => { throw error; },
);
setup.runtime.subscribeState(
state => { delivered.push(state); },
error => { throw error; },
);
await setup.runtime.start();
setup.wire.emit({ type: 'open', server: 'ws://agent' });
assert.notEqual(firstCommit, latestDuringFirstCommit);
assert.equal(delivered[0], firstCommit);
assert.equal(delivered[1], latestDuringFirstCommit);
assert.deepEqual(
delivered.map(state => (state as { app: { phase: string } }).app.phase),
['connected', 'slow'],
);
setup.runtime.stop();
});
test('cancelling and subscribing observers during a commit cannot alter that delivery snapshot', async () => {
const setup = makeRuntime();
let cancelledCalls = 0;
const replacementPhases: string[] = [];
let changedObservers = false;
let offCancelled = (): void => {};
setup.runtime.subscribeState(
() => {
if (changedObservers) return;
changedObservers = true;
offCancelled();
setup.runtime.subscribeState(
state => { replacementPhases.push(state.app.phase); },
error => { throw error; },
);
},
error => { throw error; },
);
offCancelled = setup.runtime.subscribeState(
() => { cancelledCalls += 1; },
error => { throw error; },
);
await setup.runtime.start();
setup.wire.emit({ type: 'open', server: 'ws://agent' });
assert.equal(cancelledCalls, 0);
assert.deepEqual(replacementPhases, []);
setup.wire.emit({ type: 'slow' });
assert.equal(cancelledCalls, 0);
assert.deepEqual(replacementPhases, ['slow']);
setup.runtime.stop();
});
test('a denied join retains the lobby root, reports the raw outcome and permits a valid retry', async () => {
const { runtime, wire, scene } = makeRuntime();
await runtime.start();
wire.emit({ type: 'open', server: 'ws://agent' });
runtime.login(ACCOUNT);
wire.emit({ type: 'message', message: {
route: 'agent', rpc: 'player_login', data: fixture('player-login-success.json'),
} });
const lobby = runtime.state;
const denial = { state: 99, roomcode: '999999', showerror: 1, error: 'Room does not exist' };
runtime.joinRoom({ roomcode: '999999', location: null, ip: '127.0.0.1' });
wire.emit({ type: 'message', message: { route: 'agent', rpc: 'self_join_room', data: denial } });
assert.equal(runtime.state, lobby);
assert.equal(wire.stopCalls, 0);
assert.deepEqual(scene.calls.filter(call => call.name === 'fatal'), []);
const outcome = scene.calls.find(call => call.name === 'denied')?.value;
assert.deepEqual(outcome, { rpc: 'self_join_room', state: 99, data: denial });
assert.equal((outcome as { data: unknown }).data, denial);
runtime.joinRoom({ roomcode: '100001', location: null, ip: '127.0.0.1' });
wire.emit({ type: 'message', message: {
route: 'agent', rpc: 'self_join_room', data: ({...fixture('self-join-room.json'),deskwar:0} as Record<string,unknown>),
} });
assert.equal(runtime.state.room.kind, 'inside');
assert.equal(scene.calls[scene.calls.length - 1]?.name, 'room');
runtime.stop();
});
test('runtime resolves seats only from the actual game-owned server roomtype at room entry', async () => {
const roomtypes: Array<string | readonly unknown[]> = [];
const attached: GameHost[] = [];
const setup = makeRuntime(runtimeConfig(), makeGameEntry({
resolveSeatCount(roomtype) {
assert.ok(Array.isArray(roomtype));
roomtypes.push(roomtype);
if (roomtype.length !== 7 || roomtype[1] !== 4 || !Array.isArray(roomtype[5])) {
throw new Error('Game roomtype requires the full nested server configuration');
}
return 4;
},
createModule: () => ({
attach(host) { attached.push(host); },
handlePlatformEvent() {}, handleGameMessage() {}, restore() {}, dispose() {},
}),
}));
await setup.runtime.start();
assert.deepEqual(roomtypes, []);
setup.wire.emit({ type: 'open', server: 'ws://agent' });
setup.wire.emit({ type: 'message', message: {
route: 'agent', rpc: 'player_login', data: fixture('player-login-room.json'),
} });
assert.equal(roomtypes.length, 1);
assert.equal(attached.length, 1);
assert.equal(roomtypes[0], attached[0]!.getSnapshot().room.roomtype);
assert.equal(attached[0]!.seat.toView(1), 0);
assert.deepEqual(setup.scene.calls.filter(call => call.name === 'fatal'), []);
setup.runtime.stop();
});
for (const count of [0, 2.5, 2]) {
test(`runtime rejects invalid game count ${count} at the real room boundary`, async () => {
let attaches = 0;
const setup = makeRuntime(runtimeConfig(), makeGameEntry({
resolveSeatCount: () => count,
createModule: () => ({
attach() { attaches += 1; },
handlePlatformEvent() {}, handleGameMessage() {}, restore() {}, dispose() {},
}),
}));
await setup.runtime.start();
setup.wire.emit({ type: 'open', server: 'ws://agent' });
setup.wire.emit({ type: 'message', message: {
route: 'agent', rpc: 'player_login', data: fixture('player-login-room.json'),
} });
assert.equal(attaches, 0);
assert.equal(setup.wire.stopCalls, 1);
const failure = setup.scene.calls.find(call => call.name === 'fatal')?.value;
assert.match(String((failure as { errors: unknown[] }).errors[0]), /seat count/);
setup.runtime.stop();
});
}
for(const loginRoute of ['agent','room','platform'])test(`${loginRoute} login denial clears its guard, retains state and permits account retry`, async () => {
const { runtime, wire, scene, clock } = makeRuntime();
await runtime.start();
wire.emit({ type: 'open', server: 'ws://agent' });
const before = runtime.state;
runtime.login(ACCOUNT);
const denial = { state: -1, msg: '短信验证码不正确', time: 0 };
assert.equal(wire.sent[wire.sent.length - 1]?.route,'agent','outbound login remains agent');
wire.emit({ type: 'message', message: { route: loginRoute, rpc: 'player_login', data: denial } });
assert.equal(runtime.state, before);
assert.equal(wire.stopCalls, 0);
assert.equal(clock.timers.size, 0);
const outcome = scene.calls.find(call => call.name === 'denied')?.value;
assert.deepEqual(outcome, { rpc: 'player_login', state: -1, data: denial });
assert.equal((outcome as { data: unknown }).data, denial);
assert.deepEqual(scene.calls.filter(call => call.name === 'fatal'), []);
const sentBeforeReconnect=wire.sent.length;
wire.emit({type:'reconnecting',server:'ws://agent'});
wire.emit({type:'open',server:'ws://agent'});
assert.equal(wire.sent.length,sentBeforeReconnect,'a denied first login must not be replayed');
runtime.login({ ...ACCOUNT, openid: 'retry-account' });
assert.equal((wire.sent[wire.sent.length - 1]!.data as Record<string, unknown>).openid, 'retry-account');
wire.emit({ type: 'message', message: {
route: 'agent', rpc: 'player_login', data: fixture('player-login-success.json'),
} });
assert.equal(runtime.state.app.phase, 'logged-in');
runtime.stop();
});
for (const interruption of ['attach', 'restore', 'room-scene', 'room-event'] as const) {
test(`stop from ${interruption} releases the actual module and host without reviving runtime`, async () => {
let runtime: PlatformRuntime;
let attachedHost: GameHost | undefined;
let moduleNumber = 0;
const disposed: number[] = [];
const events: string[] = [];
const entry = makeGameEntry({
createModule() {
const id = moduleNumber++;
return {
attach(host) {
attachedHost = host;
host.subscribe(() => { events.push('snapshot'); });
if (interruption === 'attach') runtime.stop();
},
handlePlatformEvent() {
events.push('room-event');
if (interruption === 'room-event') runtime.stop();
},
handleGameMessage() { events.push('game-message'); },
restore() {
events.push('restore');
if (interruption === 'restore') runtime.stop();
},
dispose() { disposed.push(id); },
};
},
});
const setup = makeRuntime(runtimeConfig(), entry);
runtime = setup.runtime;
setup.scene.onCall = name => {
if (name === 'room' && interruption === 'room-scene') runtime.stop();
};
await runtime.start();
setup.wire.emit({ type: 'open', server: 'ws://agent' });
runtime.login(ACCOUNT);
setup.wire.emit({ type: 'message', message: {
route: 'agent', rpc: 'player_login', data: fixture('player-login-room.json'),
} });
assert.deepEqual(disposed, [0], 'only the actual room module is created and disposed');
assert.ok(attachedHost);
const stoppedHost = attachedHost;
assert.throws(() => stoppedHost.getSnapshot(), /disposed/);
assert.throws(() => stoppedHost.subscribe(() => {}), /disposed/);
assert.equal(setup.clock.timers.size, 0);
assert.deepEqual(setup.scene.calls.filter(call => call.name === 'fatal'), []);
assert.equal(setup.wire.stopCalls, 1);
assert.deepEqual(setup.wire.lifecycle, ['subscribe', 'start', 'unsubscribe', 'stop']);
if (interruption === 'attach' || interruption === 'room-scene') assert.deepEqual(events, ['snapshot']);
if (interruption === 'room-event') {
assert.deepEqual(events, ['snapshot', 'room-event']);
}
const stoppedState = runtime.state;
const stoppedEvents = [...events];
setup.wire.emit({ type: 'open', server: 'ws://agent' });
setup.wire.emit({ type: 'message', message: {
route: 'fixture-game', rpc: 'late', data: {},
} });
assert.equal(runtime.state, stoppedState);
assert.deepEqual(events, stoppedEvents);
assert.throws(() => runtime.login(ACCOUNT), /stopped/);
await assert.rejects(runtime.start(), /stopped/);
runtime.stop();
assert.deepEqual(disposed, [0]);
assert.equal(setup.wire.stopCalls, 1);
});
}
test('stop reentered by an old module disposal prevents replacement room activation', async () => {
let runtime: PlatformRuntime;
const attached: GameHost[] = [];
let disposals = 0;
const setup = makeRuntime(runtimeConfig(), makeGameEntry({
createModule() {
let host: GameHost | undefined;
return {
attach(value) { host = value; attached.push(value); },
handlePlatformEvent() {}, handleGameMessage() {}, restore() {},
dispose() {
if (host === undefined) return;
disposals += 1;
runtime.stop();
},
};
},
}));
runtime = setup.runtime;
await runtime.start();
setup.wire.emit({ type: 'open', server: 'ws://agent' });
setup.wire.emit({ type: 'message', message: {
route: 'agent', rpc: 'player_login', data: fixture('player-login-room.json'),
} });
setup.wire.emit({ type: 'message', message: {
route: 'agent', rpc: 'player_login', data: fixture('player-login-room.json'),
} });
assert.equal(disposals, 1);
assert.equal(attached.length, 1);
assert.throws(() => attached[0]!.getSnapshot(), /disposed/);
assert.deepEqual(setup.scene.calls.filter(call => call.name === 'fatal'), []);
assert.equal(setup.wire.stopCalls, 1);
assert.throws(() => runtime.login(ACCOUNT), /stopped/);
runtime.stop();
});
test('an actual callback error after stop remains explicit without changing the stopped lifecycle', async () => {
let runtime: PlatformRuntime;
const failure = new Error('restore callback failed after stop');
const setup = makeRuntime(runtimeConfig(), makeGameEntry({
createModule: () => ({
attach() {}, handlePlatformEvent() {}, handleGameMessage() {}, dispose() {},
restore() { runtime.stop(); throw failure; },
}),
}));
runtime = setup.runtime;
await runtime.start();
setup.wire.emit({ type: 'open', server: 'ws://agent' });
const thrown = thrownValue(() => setup.wire.emit({ type: 'message', message: {
route: 'agent', rpc: 'player_login', data: fixture('player-login-room.json'),
} }));
assert.deepEqual((thrown as { errors: unknown[] }).errors, [failure]);
assert.deepEqual(setup.scene.calls.filter(call => call.name === 'fatal'), []);
assert.throws(() => runtime.login(ACCOUNT), /stopped/);
runtime.stop();
assert.equal(setup.wire.stopCalls, 1);
});
for (const rpc of ['player_login', 'self_join_room']) {
test(`malformed successful ${rpc} still terminates explicitly`, async () => {
const { runtime, wire, scene } = makeRuntime();
await runtime.start();
wire.emit({ type: 'open', server: 'ws://agent' });
wire.emit({ type: 'message', message: { route: 'agent', rpc, data: { state: 0 } } });
assert.equal(wire.stopCalls, 1);
assert.equal(scene.calls.filter(call => call.name === 'fatal').length, 1);
assert.deepEqual(scene.calls.filter(call => call.name === 'denied'), []);
runtime.stop();
});
}
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);
assert.throws(() => host!.getSnapshot(), /disposed/i);
log.push('dispose');
},
};
},
});
Object.defineProperty(entry, 'beginRecording', {
value: () => { recording = true; },
});
return entry;
}
function beginEntryRecording(entry: GameEntry): void {
(entry as GameEntry & { beginRecording(): void }).beginRecording();
}
interface CapturedGameHost {
host: GameHost | null;
}
function makeCleanupFailureEntry(
log: string[],
disposeFailure: unknown,
captured: CapturedGameHost,
): GameEntry {
return makeGameEntry({
createModule(): GameModule {
return {
attach(host) {
captured.host = host;
host.subscribe(() => {});
},
handlePlatformEvent() {},
handleGameMessage() {},
restore() {},
dispose() {
log.push('game:dispose');
throw disposeFailure;
},
};
},
});
}
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('showLoading failure enters terminal fatal before any startup dependency runs', async () => {
const primary = new Error('loading scene failed');
const scene = new RecordingScene();
const wire = new RecordingWireClient();
let resourcesStarted = 0;
let minimumDisplayStarted = 0;
let configsResolved = 0;
let wireFactories = 0;
scene.onCall = (name) => {
if (name === 'loading') throw primary;
};
const runtime = new PlatformRuntime({
gameEntry: makeGameEntry(),
resolveRuntimeConfig: async () => {
configsResolved += 1;
return runtimeConfig();
},
createWireClient: () => {
wireFactories += 1;
return wire;
},
scene,
loadResources: async () => { resourcesStarted += 1; },
waitForMinimumDisplay: async () => { minimumDisplayStarted += 1; },
getLoginDeviceSnapshot: () => DEVICE,
});
const thrown = await rejectedValue(runtime.start());
assert.equal(thrown, primary);
assert.deepEqual(scene.calls.map((call) => call.name), ['loading', 'fatal']);
assert.equal(scene.calls[1]?.value, primary);
assert.equal(resourcesStarted, 0);
assert.equal(minimumDisplayStarted, 0);
assert.equal(configsResolved, 0);
assert.equal(wireFactories, 0);
assert.deepEqual(wire.lifecycle, []);
assert.equal(runtime.ready, false);
await assert.rejects(() => runtime.start(), /fatal/i);
assert.equal(scene.calls.filter((call) => call.name === 'fatal').length, 1);
});
test('showLoading and showFatal failures preserve both error identities without starting dependencies', async () => {
const primary = new Error('loading scene failed');
const fatalSceneFailure = { step: 'scene.showFatal' };
const scene = new RecordingScene();
let dependencyCalls = 0;
scene.onCall = (name) => {
if (name === 'loading') throw primary;
if (name === 'fatal') throw fatalSceneFailure;
};
const runtime = new PlatformRuntime({
gameEntry: makeGameEntry(),
resolveRuntimeConfig: async () => {
dependencyCalls += 1;
return runtimeConfig();
},
createWireClient: () => {
dependencyCalls += 1;
return new RecordingWireClient();
},
scene,
loadResources: async () => { dependencyCalls += 1; },
waitForMinimumDisplay: async () => { dependencyCalls += 1; },
getLoginDeviceSnapshot: () => DEVICE,
});
const thrown = await rejectedValue(runtime.start());
assert.ok(thrown instanceof Error);
const errors = (thrown as Error & { readonly errors: readonly unknown[] }).errors;
assert.equal(errors[0], primary);
assert.equal(errors[1], fatalSceneFailure);
assert.equal(dependencyCalls, 0);
assert.deepEqual(scene.calls.map((call) => call.name), ['loading', 'fatal']);
await assert.rejects(() => runtime.start(), /fatal/i);
assert.equal(scene.calls.filter((call) => call.name === 'fatal').length, 1);
});
test('stop while config is pending prevents every late startup continuation from reviving runtime', async () => {
const configReady = deferred<RuntimeConfig>();
const resourcesReady = deferred<void>();
const minimumDisplayReady = deferred<void>();
const wire = new RecordingWireClient();
const scene = new RecordingScene();
let resourcesStarted = 0;
let minimumDisplayStarted = 0;
let wireFactories = 0;
const runtime = new PlatformRuntime({
gameEntry: makeGameEntry(),
resolveRuntimeConfig: () => configReady.promise,
createWireClient() {
wireFactories += 1;
return wire;
},
scene,
loadResources() {
resourcesStarted += 1;
return resourcesReady.promise;
},
waitForMinimumDisplay() {
minimumDisplayStarted += 1;
return minimumDisplayReady.promise;
},
getLoginDeviceSnapshot: () => DEVICE,
});
const starting = runtime.start();
await Promise.resolve();
assert.equal(resourcesStarted, 1);
assert.equal(minimumDisplayStarted, 1);
runtime.stop();
configReady.resolve(runtimeConfig());
resourcesReady.resolve(undefined);
minimumDisplayReady.resolve(undefined);
await assert.rejects(starting, /stopped/i);
await assert.rejects(() => runtime.start(), /stopped/i);
assert.equal(wireFactories, 0);
assert.deepEqual(wire.lifecycle, []);
assert.equal(runtime.ready, false);
assert.deepEqual(scene.calls.map((call) => call.name), ['loading']);
});
test('a synchronous subscribe callback that stops runtime hands the returned unsubscribe to teardown exactly once', async () => {
const unsubscribeFailure = { step: 'late-unsubscribe' };
const wire = new SynchronousSubscribeWireClient(
{ type: 'slow' },
{ value: unsubscribeFailure },
);
const scene = new RecordingScene();
let runtime!: PlatformRuntime;
runtime = new PlatformRuntime({
gameEntry: makeGameEntry(),
resolveRuntimeConfig: async () => runtimeConfig(),
createWireClient: () => wire,
scene,
loadResources: async () => {},
waitForMinimumDisplay: async () => {},
getLoginDeviceSnapshot: () => DEVICE,
});
scene.onCall = (name) => {
if (name === 'reconnect') runtime.stop();
};
const thrown = await rejectedValue(runtime.start());
assert.ok(thrown instanceof Error);
const errors = (thrown as Error & { readonly errors: readonly unknown[] }).errors;
assert.match(String(errors[0]), /stopped/i);
assert.equal(errors[1], unsubscribeFailure);
assert.equal(wire.unsubscribeCalls, 1);
assert.equal(wire.listenerAttached, false);
assert.equal(wire.stopCalls, 1);
assert.equal(wire.startCalls, 0);
const sceneCallCount = scene.calls.length;
wire.emit({ type: 'open', server: 'ws://agent' });
runtime.stop();
assert.equal(scene.calls.length, sceneCallCount);
assert.equal(wire.unsubscribeCalls, 1);
assert.equal(wire.stopCalls, 1);
});
test('a fatal synchronous subscribe callback still adopts unsubscribe and ignores late events', async () => {
const primary = new Error('synchronous reconnect scene failed');
const wire = new SynchronousSubscribeWireClient({ type: 'slow' });
const scene = new RecordingScene();
scene.onCall = (name) => {
if (name === 'reconnect') throw primary;
};
const runtime = new PlatformRuntime({
gameEntry: makeGameEntry(),
resolveRuntimeConfig: async () => runtimeConfig(),
createWireClient: () => wire,
scene,
loadResources: async () => {},
waitForMinimumDisplay: async () => {},
getLoginDeviceSnapshot: () => DEVICE,
});
const thrown = await rejectedValue(runtime.start());
assert.equal(thrown, primary);
assert.equal(wire.unsubscribeCalls, 1);
assert.equal(wire.listenerAttached, false);
assert.equal(wire.stopCalls, 1);
assert.equal(wire.startCalls, 0);
assert.deepEqual(
scene.calls.filter((call) => call.name === 'fatal').map((call) => call.value),
[primary],
);
const sceneCallCount = scene.calls.length;
wire.emit({ type: 'reconnecting', server: 'ws://agent' });
assert.equal(scene.calls.length, sceneCallCount);
});
for (const invalidUnsubscribe of [7, null, undefined] as const) {
test(`subscribe returning ${String(invalidUnsubscribe)} is fatal before Wire start`, async () => {
const wire = new InvalidSubscribeWireClient(invalidUnsubscribe);
const scene = new RecordingScene();
const runtime = new PlatformRuntime({
gameEntry: makeGameEntry(),
resolveRuntimeConfig: async () => runtimeConfig(),
createWireClient: () => wire,
scene,
loadResources: async () => {},
waitForMinimumDisplay: async () => {},
getLoginDeviceSnapshot: () => DEVICE,
});
const thrown = await rejectedValue(runtime.start());
assert.ok(thrown instanceof TypeError);
assert.match(thrown.message, /subscribe.*unsubscribe.*function/i);
assert.deepEqual(wire.lifecycle, ['subscribe', 'stop']);
assert.equal(wire.stopCalls, 1);
assert.deepEqual(
scene.calls.filter((call) => call.name === 'fatal').map((call) => call.value),
[thrown],
);
await assert.rejects(() => runtime.start(), /fatal/i);
assert.equal(scene.calls.filter((call) => call.name === 'fatal').length, 1);
});
}
test('subscribe startup failure enters fatal once and stops the created wire without starting it', async () => {
const setup = makeRuntime();
const primary = new Error('subscribe failed');
setup.wire.onAction = (name) => {
if (name === 'subscribe') throw primary;
};
const thrown = await rejectedValue(setup.runtime.start());
assert.equal(thrown, primary);
assert.deepEqual(setup.wire.lifecycle, ['subscribe', 'stop']);
assert.equal(setup.wire.stopCalls, 1);
assert.deepEqual(
setup.scene.calls.filter((call) => call.name === 'fatal').map((call) => call.value),
[primary],
);
});
test('wire start failure cleans every established layer and preserves cleanup error order', async () => {
const primary = new Error('wire start failed');
const unsubscribeFailure = { step: 'unsubscribe' };
const stopFailure = { step: 'wire.stop' };
const cleanupLog: string[] = [];
let activeHost: GameHost | null = null;
const entry = makeGameEntry({
createModule(): GameModule {
return {
attach(host) {
activeHost = host;
host.subscribe(() => {});
},
handlePlatformEvent() {},
handleGameMessage() {},
restore() {},
dispose() {
cleanupLog.push('game:dispose');
throw undefined;
},
};
},
});
const setup = makeRuntime(runtimeConfig(), entry);
setup.wire.onAction = (name) => {
if (name === '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'),
},
});
throw primary;
}
if (name === 'unsubscribe') {
cleanupLog.push('wire:unsubscribe');
throw unsubscribeFailure;
}
if (name === 'stop') {
cleanupLog.push('wire:stop');
const host = activeHost;
assert.ok(host);
assert.throws(() => host.getSnapshot(), /disposed/i);
throw stopFailure;
}
};
const thrown = await rejectedValue(setup.runtime.start());
assert.ok(thrown instanceof Error);
const errors = (thrown as Error & { readonly errors: readonly unknown[] }).errors;
assert.deepEqual(cleanupLog, ['wire:unsubscribe', 'game:dispose', 'wire:stop']);
assert.equal(errors.length, 4);
assert.equal(errors[0], primary);
assert.equal(errors[1], unsubscribeFailure);
assert.equal(errors[2], undefined);
assert.equal(errors[3], stopFailure);
assert.equal(setup.scene.calls.filter((call) => call.name === 'fatal').length, 1);
assert.equal(
setup.scene.calls.find((call) => call.name === 'fatal')?.value,
thrown,
);
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);
assert.equal(setup.wire.stopCalls, 1);
});
for (const failedGate of ['resources', 'minimum-display'] as const) {
test(`${failedGate} failure after Wire start tears down once and stays fatal`, async () => {
const resourcesReady = deferred<void>();
const minimumDisplayReady = deferred<void>();
const wireStarted = deferred<void>();
const primary = new Error(`${failedGate} failed`);
const setup = makeRuntime();
const runtime = new PlatformRuntime({
gameEntry: makeGameEntry(),
resolveRuntimeConfig: async () => runtimeConfig(),
createWireClient: () => setup.wire,
scene: setup.scene,
loadResources: () => resourcesReady.promise,
waitForMinimumDisplay: () => minimumDisplayReady.promise,
getLoginDeviceSnapshot: () => DEVICE,
clock: setup.clock,
});
setup.wire.onAction = (name) => {
if (name === 'start') wireStarted.resolve(undefined);
};
const starting = runtime.start();
await wireStarted.promise;
setup.wire.emit({ type: 'open', server: 'ws://agent' });
if (failedGate === 'resources') {
resourcesReady.reject(primary);
minimumDisplayReady.resolve(undefined);
} else {
resourcesReady.resolve(undefined);
minimumDisplayReady.reject(primary);
}
const thrown = await rejectedValue(starting);
assert.equal(thrown, primary);
assert.deepEqual(setup.wire.lifecycle, ['subscribe', 'start', 'unsubscribe', 'stop']);
assert.equal(setup.wire.stopCalls, 1);
assert.deepEqual(
setup.scene.calls.filter((call) => call.name === 'fatal').map((call) => call.value),
[primary],
);
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);
});
}
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: 'self_exit_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',
'scene:room',
'event:room.entered',
'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'),deskwar:0} as Record<string,unknown>);
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', 'scene:room', 'event:room.entered', 'restore']
: ['attach:100001', 'scene:room', 'event:room.entered']);
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' });
assert.equal(setup.runtime.state.app.phase, 'slow');
setup.wire.emit({ type: 'reconnecting', server: 'ws://agent' });
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),
['reconnect', 'reconnect'],
);
});
test('kick_offline bypasses pending login and stops late navigation', async () => {
const {runtime,wire,scene}=makeRuntime();
await runtime.start();wire.emit({type:'open',server:'ws://agent'});runtime.login(ACCOUNT);
const data={fromOther:true,gameid:'another-game'};
wire.emit({type:'message',message:{route:'agent',rpc:'kick_offline',data}});
assert.equal(scene.calls[scene.calls.length - 1]?.name,'kicked');assert.equal(scene.calls[scene.calls.length - 1]?.value,data);
assert.equal(wire.stopCalls,1);
const count=scene.calls.length;wire.emit({type:'open',server:'ws://agent'});
assert.equal(scene.calls.length,count);
});
test('show_message preserves text and legacy numeric timeout without navigating', async () => {
const {runtime,wire,scene}=makeRuntime();
await runtime.start();wire.emit({type:'open',server:'ws://agent'});
const before=runtime.state;
for(const time of ['3000','0','-1']){
wire.emit({type:'message',message:{route:'agent',rpc:'show_message',data:{msg:'服务器提示#第二行',time}}});
assert.deepEqual(scene.calls[scene.calls.length - 1],{name:'tips',value:{message:'服务器提示#第二行',time:Number(time)}});
assert.equal(runtime.state,before);
}
runtime.stop();
});
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('login gate routes each ignored room message once with zero runtime side effects', async () => {
const originalDispatch = Router.prototype.dispatch;
let routerDispatches = 0;
Router.prototype.dispatch = function dispatchWithCount(message) {
routerDispatches += 1;
return originalDispatch.call(this, message);
};
try {
const log: string[] = [];
const entry = makeLoggingEntry(log, [], []);
const setup = makeRuntime(runtimeConfig(), entry);
await setup.runtime.start();
beginEntryRecording(entry);
setup.wire.emit({ type: 'open', server: 'ws://agent' });
setup.runtime.login(ACCOUNT);
const stateBefore = setup.runtime.state;
const sceneCallsBefore = setup.scene.calls.length;
const lifecycleBefore = [...setup.wire.lifecycle];
setup.wire.emit({
type: 'message',
message: { route: 'room', rpc: 'self_exit_room', data: { seat: 1 } },
});
setup.wire.emit({
type: 'message',
message: { route: 'agent', rpc: 'self_join_room', data: ({...fixture('self-join-room.json'),deskwar:0} as Record<string,unknown>) },
});
assert.equal(routerDispatches, 2);
assert.equal(setup.runtime.state, stateBefore);
assert.deepEqual(log, []);
assert.equal(setup.scene.calls.length, sceneCallsBefore);
assert.equal(setup.scene.calls.filter((call) => call.name === 'fatal').length, 0);
assert.deepEqual(setup.wire.lifecycle, lifecycleBefore);
assert.equal(setup.wire.stopCalls, 0);
} finally {
Router.prototype.dispatch = originalDispatch;
}
});
test('a handled no-op room message cannot drive a scene without a state transition', 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') },
});
const stateBefore = setup.runtime.state;
const sceneCallsBefore = setup.scene.calls.length;
setup.wire.emit({
type: 'message',
message: { route: 'room', rpc: 'self_exit_room', data: {} },
});
assert.equal(setup.runtime.state, stateBefore);
assert.equal(setup.scene.calls.length, sceneCallsBefore);
assert.equal(setup.scene.calls.filter((call) => call.name === 'fatal').length, 0);
});
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'),deskwar:0} as Record<string,unknown>);
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, revokes the host before module disposal, 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);
});
test('stop continues after clearTimeout and every later cleanup failure in exact order', async () => {
const clearFailure = { step: 'clock.clearTimeout' };
const disposeFailure = { step: 'game.dispose' };
const leaseFailure = { step: 'lease.invalidate' };
const stopFailure = { step: 'wire.stop' };
const log: string[] = [];
const captured: CapturedGameHost = { host: null };
const clock = new ControlledClearClock();
const wire = new RecordingWireClient();
const scene = new RecordingScene();
const originalStoreSubscribe = PlatformStore.prototype.subscribe;
PlatformStore.prototype.subscribe = function subscribeWithFailingRelease(listener) {
const release = originalStoreSubscribe.call(this, listener);
return () => {
release();
log.push('lease:invalidate');
throw leaseFailure;
};
};
try {
const runtime = new PlatformRuntime({
gameEntry: makeCleanupFailureEntry(log, disposeFailure, captured),
resolveRuntimeConfig: async () => runtimeConfig(),
createWireClient: () => wire,
scene,
loadResources: async () => {},
waitForMinimumDisplay: async () => {},
getLoginDeviceSnapshot: () => DEVICE,
clock,
});
clock.onFailingClear = () => { log.push('clock:clear'); };
wire.onAction = (name) => {
if (name === 'unsubscribe') {
log.push('wire:unsubscribe');
throw undefined;
}
if (name === 'stop') {
log.push('wire:stop');
const host = captured.host;
assert.ok(host);
assert.throws(() => host.getSnapshot(), /disposed/i);
throw stopFailure;
}
};
await runtime.start();
wire.emit({ type: 'open', server: 'ws://agent' });
runtime.login(ACCOUNT);
wire.emit({
type: 'message',
message: { route: 'agent', rpc: 'player_login', data: fixture('player-login-room.json') },
});
wire.emit({ type: 'open', server: 'ws://agent' });
assert.equal(clock.timers.size, 1);
clock.enableClearFailure(clearFailure);
log.length = 0;
const thrown = thrownValue(() => { runtime.stop(); });
assert.ok(thrown instanceof Error);
const errors = (thrown as Error & { readonly errors: readonly unknown[] }).errors;
assert.deepEqual(log, [
'clock:clear',
'wire:unsubscribe',
'lease:invalidate',
'game:dispose',
'wire:stop',
]);
assert.equal(errors.length, 4);
assert.equal(errors[0], clearFailure);
assert.equal(errors[1], undefined);
assert.deepEqual((errors[2] as {errors:unknown[]}).errors, [leaseFailure, disposeFailure]);
assert.equal(errors[3], stopFailure);
assert.equal(scene.calls.filter((call) => call.name === 'fatal').length, 0);
const completedLog = [...log];
assert.doesNotThrow(() => { runtime.stop(); });
assert.deepEqual(log, completedLog);
assert.equal(wire.stopCalls, 1);
} finally {
PlatformStore.prototype.subscribe = originalStoreSubscribe;
}
});
test('kick completes close, invalidate, clear, kicked scene, and wire stop despite all failures', async () => {
const disposeFailure = { step: 'game.dispose' };
const leaseFailure = { step: 'lease.invalidate' };
const clearFailure = { step: 'clock.clearTimeout' };
const stopFailure = { step: 'wire.stop' };
const log: string[] = [];
const captured: CapturedGameHost = { host: null };
const clock = new ControlledClearClock();
const wire = new RecordingWireClient();
const scene = new RecordingScene();
const originalStoreSubscribe = PlatformStore.prototype.subscribe;
PlatformStore.prototype.subscribe = function subscribeWithFailingRelease(listener) {
const release = originalStoreSubscribe.call(this, listener);
return () => {
release();
log.push('lease:invalidate');
throw leaseFailure;
};
};
try {
const runtime = new PlatformRuntime({
gameEntry: makeCleanupFailureEntry(log, disposeFailure, captured),
resolveRuntimeConfig: async () => runtimeConfig(),
createWireClient: () => wire,
scene,
loadResources: async () => {},
waitForMinimumDisplay: async () => {},
getLoginDeviceSnapshot: () => DEVICE,
clock,
});
clock.onFailingClear = () => { log.push('clock:clear'); };
scene.onCall = (name) => {
if (name === 'kicked') {
log.push('scene:kicked');
throw undefined;
}
if (name === 'fatal') log.push('scene:fatal');
};
wire.onAction = (name) => {
if (name === 'unsubscribe') log.push('wire:unsubscribe');
if (name === 'stop') {
log.push('wire:stop');
const host = captured.host;
assert.ok(host);
assert.throws(() => host.getSnapshot(), /disposed/i);
throw stopFailure;
}
};
await runtime.start();
wire.emit({ type: 'open', server: 'ws://agent' });
runtime.login(ACCOUNT);
wire.emit({
type: 'message',
message: { route: 'agent', rpc: 'player_login', data: fixture('player-login-room.json') },
});
wire.emit({ type: 'open', server: 'ws://agent' });
assert.equal(clock.timers.size, 1);
clock.enableClearFailure(clearFailure);
log.length = 0;
const kick = { msg: 'terminal cleanup test', code: 1 };
wire.emit({
type: 'message',
message: { route: 'agent', rpc: 'kick_server', data: kick },
});
assert.deepEqual(log, [
'lease:invalidate',
'game:dispose',
'clock:clear',
'scene:kicked',
'wire:stop',
'wire:unsubscribe',
'scene:fatal',
]);
assert.equal(wire.stopCalls, 1);
assert.equal(scene.calls.filter((call) => call.name === 'kicked').length, 1);
assert.equal(scene.calls.find((call) => call.name === 'kicked')?.value, kick);
const fatal = scene.calls.find((call) => call.name === 'fatal')?.value;
assert.ok(fatal instanceof Error);
const sessionErrors = (fatal as Error & { readonly errors: readonly unknown[] }).errors;
assert.equal(sessionErrors.length, 2);
assert.deepEqual((sessionErrors[0] as {errors:unknown[]}).errors, [leaseFailure, disposeFailure]);
const kickCleanup = sessionErrors[1];
assert.ok(kickCleanup instanceof Error);
const kickErrors = (
kickCleanup as Error & { readonly errors: readonly unknown[] }
).errors;
assert.equal(kickErrors.length, 3);
assert.equal(kickErrors[0], clearFailure);
assert.equal(kickErrors[1], undefined);
assert.equal(kickErrors[2], stopFailure);
const completedLog = [...log];
assert.doesNotThrow(() => { runtime.stop(); });
assert.doesNotThrow(() => { runtime.stop(); });
assert.deepEqual(log, completedLog);
assert.equal(wire.stopCalls, 1);
} finally {
PlatformStore.prototype.subscribe = originalStoreSubscribe;
}
});
test('real runtime renders the room before restoring canonical deskinfo', async () => {
const events: string[] = [];
const wire = new RecordingWireClient();
const controller = new PlatformUiController({
render: model => { events.push(`render:${model.page}`); },
activate: page => { events.push(`activate:${page}`); },
showOverlay: model => { events.push(`overlay:${model.kind}`); },
}, { request: () => () => {} }, error => { throw error; });
const deskinfo = { fixture: 'opaque' };
const entry = makeGameEntry({ createModule: () => ({
attach() { events.push('attach'); },
handlePlatformEvent() {}, handleGameMessage() {}, dispose() {},
restore(value) { assert.equal(value, deskinfo); events.push('restore'); },
}) });
const runtime = new PlatformRuntime({
gameEntry: entry, resolveRuntimeConfig: async () => runtimeConfig(),
createWireClient: () => wire, scene: controller,
loadResources: async () => {}, waitForMinimumDisplay: async () => {},
getLoginDeviceSnapshot: () => DEVICE, clock: new ManualClock(),
});
controller.connect(runtime);
await runtime.start();
wire.emit({ type: 'open', server: 'ws://agent' });
controller.login(ACCOUNT);
wire.emit({ type: 'message', message: { route: 'agent', rpc: 'player_login',
data: fixture('player-login-success.json') } });
controller.joinRoom({ roomcode: '100001', location: null, ip: '127.0.0.1' });
wire.emit({ type: 'message', message: { route: 'agent', rpc: 'self_join_room',
data: { ...({...fixture('self-join-room.json'),deskwar:0} as Record<string,unknown>), deskinfo } } });
assert.ok(events.includes('render:lobby'));
assert.ok(events.indexOf('attach') < events.indexOf('render:room'));
assert.ok(events.indexOf('render:room') < events.indexOf('activate:room'));
assert.ok(events.indexOf('activate:room') < events.indexOf('restore'));
controller.dispose(); runtime.stop();
assert.equal(wire.stopCalls, 1);
});
test('real runtime keeps canonical roots and raw denials while login and join retries succeed', async () => {
const wire = new RecordingWireClient();
const overlays: unknown[] = [];
const controller = new PlatformUiController({
render: () => {}, activate: () => {},
showOverlay: model => { overlays.push(model); },
}, { request: () => () => {} }, error => { throw error; });
const runtime = new PlatformRuntime({
gameEntry: makeGameEntry(), resolveRuntimeConfig: async () => runtimeConfig(),
createWireClient: () => wire, scene: controller,
loadResources: async () => {}, waitForMinimumDisplay: async () => {},
getLoginDeviceSnapshot: () => DEVICE, clock: new ManualClock(),
});
controller.connect(runtime);
await runtime.start();
wire.emit({ type: 'open', server: 'ws://agent' });
const preLoginRoot = runtime.state;
controller.login(ACCOUNT);
const loginDenial = { state: 7, showerror: 1, error: 'Login denied' };
wire.emit({ type: 'message', message: {
route: 'agent', rpc: 'player_login', data: loginDenial,
} });
assert.equal(runtime.state, preLoginRoot);
const loginOverlay = overlays[overlays.length - 1] as { kind: string; outcome: { data: unknown } };
assert.equal(loginOverlay.kind, 'denial');
assert.equal(loginOverlay.outcome.data, loginDenial);
const retryAccount = { ...ACCOUNT, openid: 'retry-account' };
controller.login(retryAccount);
assert.equal((wire.sent[wire.sent.length - 1]!.data as Record<string, unknown>).openid, 'retry-account');
wire.emit({ type: 'message', message: {
route: 'agent', rpc: 'player_login', data: fixture('player-login-success.json'),
} });
const lobbyRoot = runtime.state;
const firstJoin = { roomcode: '999999', location: null, ip: '127.0.0.1' };
controller.joinRoom(firstJoin);
const joinDenial = { state: 99, roomcode: '999999', showerror: 1, error: 'No room' };
wire.emit({ type: 'message', message: {
route: 'agent', rpc: 'self_join_room', data: joinDenial,
} });
assert.equal(runtime.state, lobbyRoot);
const joinOverlay = overlays[overlays.length - 1] as { kind: string; outcome: { data: unknown } };
assert.equal(joinOverlay.kind, 'denial');
assert.equal(joinOverlay.outcome.data, joinDenial);
controller.joinRoom({ roomcode: '100001', location: null, ip: '127.0.0.1' });
wire.emit({ type: 'message', message: {
route: 'agent', rpc: 'self_join_room', data: ({...fixture('self-join-room.json'),deskwar:0} as Record<string,unknown>),
} });
assert.equal(runtime.state.room.kind, 'inside');
assert.equal((overlays[overlays.length - 1] as { kind: string }).kind, 'none');
controller.dispose(); runtime.stop();
});
test('real runtime restores reconnect overlay and silences UI after kick or fatal', async () => {
for (const terminal of ['kick', 'fatal'] as const) {
const wire = new RecordingWireClient();
const events: string[] = [];
const controller = new PlatformUiController({
render: model => { events.push(`render:${model.page}`); },
activate: page => { events.push(`activate:${page}`); },
showOverlay: model => { events.push(`overlay:${model.kind}`); },
}, { request: () => () => {} }, error => { throw error; });
const runtime = new PlatformRuntime({
gameEntry: makeGameEntry(), resolveRuntimeConfig: async () => runtimeConfig(),
createWireClient: () => wire, scene: controller,
loadResources: async () => {}, waitForMinimumDisplay: async () => {},
getLoginDeviceSnapshot: () => DEVICE, clock: new ManualClock(),
});
controller.connect(runtime);
await runtime.start();
wire.emit({ type: 'open', server: 'ws://agent' });
controller.login(ACCOUNT);
wire.emit({ type: 'message', message: {
route: 'agent', rpc: 'player_login', data: fixture('player-login-success.json'),
} });
wire.emit({ type: 'slow' });
assert.equal(events[events.length - 1], 'overlay:reconnect');
const activations = events.filter(event => event.startsWith('activate:')).length;
wire.emit({ type: 'open', server: 'ws://agent' });
assert.equal(events[events.length - 1], 'overlay:none');
assert.equal(events.filter(event => event.startsWith('activate:')).length, activations);
if (terminal === 'kick') {
wire.emit({ type: 'message', message: {
route: 'agent', rpc: 'kick_server', data: { code: 1, msg: 'terminal' },
} });
assert.equal(events[events.length - 1], 'overlay:kicked');
} else {
wire.emit({ type: 'message', message: {
route: 'agent', rpc: 'player_login', data: { state: 0 },
} });
assert.equal(events[events.length - 1], 'overlay:fatal');
}
const terminalEvents = [...events];
assert.throws(() => controller.login(ACCOUNT), /terminal/);
wire.emit({ type: 'slow' });
wire.emit({ type: 'open', server: 'ws://agent' });
assert.deepEqual(events, terminalEvents);
assert.equal(wire.stopCalls, 1);
controller.dispose(); runtime.stop();
}
});
test('real runtime alone governs login readiness while resources are pending', async () => {
const resourcesReady = deferred<void>();
const wire = new RecordingWireClient();
const controller = new PlatformUiController({
render: () => {}, activate: () => {}, showOverlay: () => {},
}, { request: () => () => {} }, error => { throw error; });
const runtime = new PlatformRuntime({
gameEntry: makeGameEntry(), resolveRuntimeConfig: async () => runtimeConfig(),
createWireClient: () => wire, scene: controller,
loadResources: () => resourcesReady.promise, waitForMinimumDisplay: async () => {},
getLoginDeviceSnapshot: () => DEVICE, clock: new ManualClock(),
});
controller.connect(runtime);
const starting = runtime.start();
await Promise.resolve();
await Promise.resolve();
assert.deepEqual(wire.lifecycle, ['subscribe', 'start']);
wire.emit({ type: 'open', server: 'ws://agent' });
assert.equal(runtime.ready, false);
assert.throws(() => controller.login(ACCOUNT), /login page/);
assert.equal(wire.sent.length, 0);
resourcesReady.resolve(undefined);
await starting;
assert.equal(runtime.ready, true);
controller.login(ACCOUNT);
assert.equal(wire.sent.length, 1);
controller.dispose(); runtime.stop();
});
for (const phase of ['render', 'activate'] as const) {
test(`real room ${phase} disposal interrupts restore and terminates the runtime`, async () => {
const wire = new RecordingWireClient();
let armed = false;
let restores = 0;
let controller!: PlatformUiController;
controller = new PlatformUiController({
render: model => {
if (armed && phase === 'render' && model.page === 'room') controller.dispose();
},
activate: page => {
if (armed && phase === 'activate' && page === 'room') controller.dispose();
},
showOverlay: () => {},
}, { request: () => () => {} }, error => { throw error; });
const runtime = new PlatformRuntime({
gameEntry: makeGameEntry({ createModule: () => ({
attach() {}, handlePlatformEvent() {}, handleGameMessage() {}, dispose() {},
restore() { restores += 1; },
}) }),
resolveRuntimeConfig: async () => runtimeConfig(),
createWireClient: () => wire, scene: controller,
loadResources: async () => {}, waitForMinimumDisplay: async () => {},
getLoginDeviceSnapshot: () => DEVICE, clock: new ManualClock(),
});
controller.connect(runtime);
await runtime.start();
wire.emit({ type: 'open', server: 'ws://agent' });
controller.login(ACCOUNT);
wire.emit({ type: 'message', message: {
route: 'agent', rpc: 'player_login', data: fixture('player-login-success.json'),
} });
armed = true;
controller.joinRoom({ roomcode: '100001', location: null, ip: '127.0.0.1' });
wire.emit({ type: 'message', message: {
route: 'agent', rpc: 'self_join_room', data: ({...fixture('self-join-room.json'),deskwar:0} as Record<string,unknown>),
} });
assert.equal(restores, 0);
assert.equal(wire.stopCalls, 1);
assert.throws(() => runtime.login(ACCOUNT), error => {
assert.ok(error instanceof Error);
const errors = (error as Error & { readonly errors: readonly unknown[] }).errors;
assert.match(String(errors[0]), /lifecycle interrupted/);
return true;
});
runtime.stop();
});
}
test('application overlay teardown disposes Presenter, stops Runtime, and invalidates a late frame', async () => {
const wire = new RecordingWireClient();
const frames: Array<{ callback: () => void; cancelled: boolean }> = [];
const renders: string[] = [];
let armed = false;
let runtime!: PlatformRuntime;
let controller!: PlatformUiController;
controller = new PlatformUiController({
render: model => { renders.push(model.page); },
activate: () => {},
showOverlay: model => {
if (armed && model.kind === 'reconnect') {
controller.dispose();
runtime.stop();
}
},
}, { request: callback => {
const frame = { callback, cancelled: false };
frames.push(frame);
return () => { frame.cancelled = true; };
} }, error => { throw error; });
runtime = new PlatformRuntime({
gameEntry: makeGameEntry(), resolveRuntimeConfig: async () => runtimeConfig(),
createWireClient: () => wire, scene: controller,
loadResources: async () => {}, waitForMinimumDisplay: async () => {},
getLoginDeviceSnapshot: () => DEVICE, clock: new ManualClock(),
});
controller.connect(runtime);
await runtime.start();
wire.emit({ type: 'open', server: 'ws://agent' });
controller.login(ACCOUNT);
wire.emit({ type: 'message', message: {
route: 'agent', rpc: 'player_login', data: fixture('player-login-success.json'),
} });
armed = true;
const framesBeforeStop = frames.length;
wire.emit({ type: 'slow' });
assert.equal(frames.length, framesBeforeStop + 1);
const lateFrame = frames[frames.length - 1]!;
assert.equal(lateFrame.cancelled, true);
assert.equal(wire.stopCalls, 1);
const rendered = [...renders];
lateFrame.callback();
wire.emit({ type: 'open', server: 'ws://agent' });
assert.deepEqual(renders, rendered);
assert.throws(() => runtime.login(ACCOUNT), /stopped/);
runtime.stop();
});
test('rank and card wire flow uses server balances and accepts repeated rank responses',async()=>{
const {runtime,wire}=makeRuntime();await runtime.start();
wire.emit({type:'open',server:'ws://agent'});runtime.login(ACCOUNT);
wire.emit({type:'message',message:{route:'agent',rpc:'player_login',data:fixture('player-login-success.json')}});
runtime.getTreasureList();const row={name:'甲',headimg:'',playerid:12,star:100,signature:'a#b'};
wire.emit({type:'message',message:{route:'agent',rpc:'get_treasurelist',data:{list:[row]}}});
assert.deepEqual(runtime.state.treasureList,[row]);const old=runtime.state.treasureList;
wire.emit({type:'message',message:{route:'agent',rpc:'get_treasurelist',data:{list:[row]}}});
assert.notEqual(runtime.state.treasureList,old);
runtime.redeemCard('00aB-12');const bean=runtime.state.players.entities[430511].bean;
wire.emit({type:'message',message:{route:'agent',rpc:'topup_card',data:{}}});
assert.equal(runtime.state.players.entities[430511].bean,bean);
for(const route of ['agent','room']){
wire.emit({type:'message',message:{route,rpc:'update_bean',data:{bean:123}}});
assert.equal(runtime.state.players.entities[430511].bean,123);
}
wire.emit({type:'message',message:{route:'agent',rpc:'update_bean',data:{change:20}}});
assert.equal(runtime.state.players.entities[430511].bean,123);runtime.stop();
});
test('create wire outcome enters only on success and a repeated success leaves the module mounted',async()=>{
let attachments=0,disposals=0;
const entry=makeGameEntry({resolveSeatCount:()=>10,createModule:()=>({
attach(){attachments++;},handlePlatformEvent(){},handleGameMessage(){},restore(){},dispose(){disposals++;},
})});
const {runtime,wire,scene}=makeRuntime(runtimeConfig(),entry);
await runtime.start();attachments=0;disposals=0;wire.emit({type:'open',server:'ws://agent'});runtime.login(ACCOUNT);
wire.emit({type:'message',message:{route:'agent',rpc:'player_login',data:fixture('player-login-success.json')}});
runtime.createRoom({roomtype:[1,4],location:null,ip:'127.0.0.1'});
assert.equal(wire.sent[wire.sent.length-1]?.rpc,'create_room');
const before=runtime.state;
wire.emit({type:'message',message:{route:'agent',rpc:'create_room',data:{state:1,showerror:1,error:'denied'}}});
assert.equal(runtime.state,before);assert.equal(attachments,0);
assert.equal(scene.calls.some(call=>call.name==='room'||call.name==='fatal'),false);
runtime.createRoom({roomtype:[1,4],location:null,ip:'127.0.0.1'});
const data={state:0,roomcode:'123456',seat:0,roomtype:[1,4],needprepare:1,infinite:0};
wire.emit({type:'message',message:{route:'agent',rpc:'create_room',data}});
wire.emit({type:'message',message:{route:'agent',rpc:'create_room',data:{...data}}});
assert.equal(attachments,1);assert.equal(disposals,0);
assert.equal(scene.calls.filter(call=>call.name==='room').length,1);
assert.equal(runtime.state.room.kind,'inside');
if(runtime.state.room.kind==='inside')assert.equal(runtime.state.room.seatPlayerIds.length,10);
runtime.stop();assert.equal(disposals,1);
});
for(const responseRoomtype of ['11011000000','11011','11011000001']){
test(`erqiwang creation preserves response ${responseRoomtype}, three seats and room host exit`,async()=>{
const config=runtimeConfig(),game=GAME_DEFINITIONS.erqiwang.createGame(config.identity.gameid);
const {runtime,wire,scene}=makeRuntime(config,game.entry);await runtime.start();
wire.emit({type:'open',server:'ws://agent'});runtime.login(ACCOUNT);
wire.emit({type:'message',message:{route:'agent',rpc:'player_login',data:fixture('player-login-success.json')}});
const roomtype='11011000000';runtime.createRoom({roomtype,location:null,ip:'127.0.0.1'});
assert.equal((wire.sent[wire.sent.length - 1]?.data as {roomtype:unknown}).roomtype,roomtype);
wire.emit({type:'message',message:{route:'agent',rpc:'create_room',data:{state:0,roomcode:123456,seat:0,roomtype:responseRoomtype,needprepare:1}}});
assert.equal(runtime.state.room.kind,'inside');
if(runtime.state.room.kind==='inside'){assert.equal(runtime.state.room.roomcode,'123456');assert.equal(runtime.state.room.roomtype,responseRoomtype);assert.equal(runtime.state.room.seatPlayerIds.length,3);}
assert.equal(scene.calls.filter(x=>x.name==='room').length,1);
assert.equal(scene.calls.some(x=>x.name==='fatal'),false);assert.equal(wire.stopCalls,0);
let rendered=false;game.mount({render(snapshot,actions){assert.equal(snapshot.room.roomtype,responseRoomtype);rendered=true;actions.exit();},close(){}});
assert.ok(rendered);assert.equal(wire.sent[wire.sent.length - 1]?.rpc,'self_exit_room');runtime.stop();
});
}
for(const roomtype of ['11011','11011000001']){
test(`erqiwang login restores compatible roomtype ${roomtype} without closing connection`,async()=>{
const config=runtimeConfig(),game=GAME_DEFINITIONS.erqiwang.createGame(config.identity.gameid);
const {runtime,wire,scene}=makeRuntime(config,game.entry);await runtime.start();
wire.emit({type:'open',server:'ws://agent'});runtime.login(ACCOUNT);
const data=fixture('player-login-room.json');
data.roomtype=roomtype;data.players=(data.players as unknown[]).slice(0,3);
wire.emit({type:'message',message:{route:'agent',rpc:'player_login',data}});
assert.equal(scene.calls.some(x=>x.name==='fatal'),false);assert.equal(wire.stopCalls,0);
assert.equal(scene.calls.filter(x=>x.name==='room').length,1);
assert.equal(runtime.state.room.kind,'inside');
if(runtime.state.room.kind==='inside')assert.equal(runtime.state.room.roomtype,roomtype);
runtime.stop();
});
}
for(const entry of ['login restore','join'] as const){
test(`numeric roomcode survives ${entry} through Router, Store and room actions`,async()=>{
const {runtime,wire,scene}=makeRuntime();await runtime.start();
wire.emit({type:'open',server:'ws://agent'});runtime.login(ACCOUNT);
const raw={...fixture(entry==='login restore'?'player-login-room.json':'self-join-room.json'),roomcode:123456};
if(entry==='join')wire.emit({type:'message',message:{route:'agent',rpc:'player_login',data:fixture('player-login-success.json')}});
wire.emit({type:'message',message:{route:'agent',rpc:entry==='login restore'?'player_login':'self_join_room',data:raw}});
assert.equal(runtime.state.room.kind,'inside');
if(runtime.state.room.kind!=='inside')throw Error('Room response did not commit');
assert.equal(runtime.state.room.roomcode,'123456');assert.equal(raw.roomcode,123456);
assert.equal(scene.calls.filter(call=>call.name==='room').length,1);
assert.equal(scene.calls.some(call=>call.name==='fatal'),false);
runtime.sendRoomText('测试',false);
assert.equal(wire.sent[wire.sent.length-1]?.rpc,'send_text');
assert.equal((wire.sent[wire.sent.length-1]?.data as {roomcode:string}).roomcode,'123456');
runtime.stop();
});
}
test('dissolution confirmation without deskfree returns to lobby with no exit packet',async()=>{
const {runtime,wire,scene}=makeRuntime();await runtime.start();wire.emit({type:'open',server:'ws://agent'});runtime.login(ACCOUNT);
wire.emit({type:'message',message:{route:'agent',rpc:'player_login',data:fixture('player-login-room.json')}});
wire.emit({type:'message',message:{route:'room',rpc:'free_room',data:{seats:[null,0,null,null]}}});
const sent=wire.sent.length;runtime.acknowledgeDissolution();assert.equal(runtime.state.room.kind,'outside');assert.equal(wire.sent.length,sent);assert.equal(scene.calls[scene.calls.length-1]?.name,'lobby');runtime.stop();
});
test('freeNow without deskfree closes room and displays original tips immediately',async()=>{
const {runtime,wire,scene}=makeRuntime();await runtime.start();wire.emit({type:'open',server:'ws://agent'});runtime.login(ACCOUNT);
wire.emit({type:'message',message:{route:'agent',rpc:'player_login',data:fixture('player-login-room.json')}});
wire.emit({type:'message',message:{route:'room',rpc:'free_room',data:{freeNow:1,tips:'房间已解散'}}});
assert.equal(runtime.state.room.kind,'outside');assert.ok(scene.calls.some(call=>call.name==='tips'&&(call.value as {message:string}).message==='房间已解散'));runtime.stop();
});
test('dissolution server switch preserves session until agent free_room confirmation',async()=>{
let disposed=0;const events:PlatformToGameEvent[]=[];
const entry=makeGameEntry({createModule:()=>({attach(){},handlePlatformEvent(event){events.push(event);},handleGameMessage(){},restore(){},dispose(){disposed++;}})});
const {runtime,wire}=makeRuntime(runtimeConfig(),entry);await runtime.start();wire.emit({type:'open',server:'ws://agent'});runtime.login(ACCOUNT);
wire.emit({type:'message',message:{route:'agent',rpc:'player_login',data:fixture('player-login-room.json')}});
disposed=0;
const payload={agentserver:'new-agent:9000',data:{opt:'free_room',playerid:430511,agentid:'veRa0qrBf',gameid:'41',info:{seats:[null,0,null,null],deskfree:{round:3}}}};
wire.emit({type:'message',message:{route:'room',rpc:'connect_agentserver',data:payload}});
assert.equal(disposed,0);wire.emit({type:'open',server:'ws://new-agent:9000'});assert.deepEqual(wire.sent[wire.sent.length-1]?.data,payload);
wire.emit({type:'message',message:{route:'agent',rpc:'free_room',data:payload.data.info}});
runtime.acknowledgeDissolution();assert.equal(disposed,0);assert.deepEqual(events[events.length-1],{type:'room.dissolution-confirmed',freeNow:false,deskfree:{round:3}});runtime.stop();assert.equal(disposed,1);
});
test('freeNow and dissolution server switch work in either order',async()=>{
for(const switchFirst of [true,false]){
const {runtime,wire,scene}=makeRuntime();await runtime.start();wire.emit({type:'open',server:'ws://agent'});runtime.login(ACCOUNT);
wire.emit({type:'message',message:{route:'agent',rpc:'player_login',data:fixture('player-login-room.json')}});
const switchMessage={route:'room',rpc:'connect_agentserver',data:{agentserver:'new-agent:9000',data:{opt:'free_room'}}};
if(switchFirst){wire.emit({type:'message',message:switchMessage});wire.emit({type:'open',server:'ws://new-agent:9000'});}
wire.emit({type:'message',message:{route:switchFirst?'agent':'room',rpc:'free_room',data:{freeNow:1,tips:'已解散'}}});
assert.equal(runtime.state.room.kind,'outside');
if(!switchFirst){wire.emit({type:'message',message:switchMessage});assert.deepEqual(wire.switchCalls,['ws://new-agent:9000']);assert.equal(scene.calls.some(call=>call.name==='fatal'),false);}
else assert.equal(scene.calls.some(call=>call.name==='fatal'),false);
runtime.stop();
}
});
test('empty server self agree/refuse acknowledgement envelopes update the active vote',async()=>{
for(const rpc of ['self_agree_free_room','self_refuse_free_room']){
const {runtime,wire,scene}=makeRuntime();await runtime.start();wire.emit({type:'open',server:'ws://agent'});runtime.login(ACCOUNT);
const data=fixture('player-login-room.json'),players=data.players as Record<string,unknown>[];players[0]={...players[1],playerid:999,nickname:'发起者'};
wire.emit({type:'message',message:{route:'agent',rpc:'player_login',data}});
wire.emit({type:'message',message:{route:'room',rpc:'other_apply_free_room',data:{seat:0,agreefree:{state:[0,-1,null,null],countdown:30}}}});
const decoded=decodeFrame(JSON.stringify({app:'youle',route:'room',rpc}));assert.ok(decoded.kind==='message');wire.emit({type:'message',message:decoded.message});
assert.equal(scene.calls.some(call=>call.name==='fatal'),false);assert.equal(runtime.state.dissolution?.kind,rpc==='self_agree_free_room'?'active':'result');runtime.stop();
}
});
async function runtimeWithPendingRoomMount(){
const mount=deferred<void>(),events:string[]=[],restored:unknown[]=[],hosts:GameHost[]=[];
const setup=makeRuntime(runtimeConfig(),makeGameEntry({createModule(){
let attached=false;
return {attach(host){attached=true;hosts.push(host);events.push('attach');},
handlePlatformEvent(event){events.push(event.type);},handleGameMessage(message){events.push(`game:${message.rpc}`);},
restore(value){restored.push(value);events.push('restore');},dispose(){if(attached)events.push('dispose');}};
}}));
const port:ScenePort=setup.scene;
port.showRoom=async scope=>{
events.push('mount');await mount.promise;
scope.own(()=>events.push('view:release'));
if(scope.active)events.push('view:ready');
};
await setup.runtime.start();setup.wire.emit({type:'open',server:'ws://agent'});setup.runtime.login(ACCOUNT);
const login=fixture('player-login-room.json');
setup.wire.emit({type:'message',message:{route:'agent',rpc:'player_login',data:login}});
return {...setup,mount,events,restored,hosts,login};
}
async function settleRoomMount():Promise<void>{await Promise.resolve();await Promise.resolve();await Promise.resolve();}
test('real runtime waits for scene mount before entered, exact restore, and queued game input',async()=>{
const setup=await runtimeWithPendingRoomMount();
setup.wire.emit({type:'message',message:{route:'fixture-game',rpc:'one',data:{}}});
setup.wire.emit({type:'message',message:{route:'fixture-game',rpc:'two',data:{}}});
assert.deepEqual(setup.events,['attach','mount']);assert.equal(setup.clock.timers.size,1);
setup.mount.resolve();await settleRoomMount();
assert.deepEqual(setup.events,['attach','mount','view:ready','room.entered','restore','game:one','game:two']);
assert.equal(setup.restored[0],setup.login.deskinfo);assert.equal(setup.clock.timers.size,0);
assert.equal(setup.scene.calls.filter(call=>call.name==='fatal').length,0);setup.runtime.stop();
});
test('real runtime kick cancels a pending scene and never delivers its queued input',async()=>{
const setup=await runtimeWithPendingRoomMount();
setup.wire.emit({type:'message',message:{route:'fixture-game',rpc:'pending',data:{}}});
setup.wire.emit({type:'message',message:{route:'agent',rpc:'kick_server',data:{msg:'检查到新版本',code:1}}});
assert.throws(()=>setup.hosts[0]!.getSnapshot(),/disposed/);assert.equal(setup.clock.timers.size,0);
setup.mount.resolve();await settleRoomMount();
assert.deepEqual(setup.events,['attach','mount','dispose','view:release']);
assert.equal(setup.scene.calls.filter(call=>call.name==='kicked').length,1);assert.equal(setup.scene.calls.filter(call=>call.name==='fatal').length,0);
assert.equal(setup.wire.stopCalls,1);
});
test('real runtime mount rejection fails once and releases pending input and host lease',async()=>{
const setup=await runtimeWithPendingRoomMount(),cause=Error('scene load rejected');
setup.mount.reject(cause);await settleRoomMount();
const fatals=setup.scene.calls.filter(call=>call.name==='fatal');assert.equal(fatals.length,1);
assert.deepEqual((fatals[0]!.value as {errors:unknown[]}).errors,[cause]);
assert.throws(()=>setup.hosts[0]!.getSnapshot(),/disposed/);assert.equal(setup.clock.timers.size,0);assert.equal(setup.wire.stopCalls,1);
assert.deepEqual(setup.events,['attach','mount','dispose']);setup.runtime.stop();assert.equal(setup.scene.calls.filter(call=>call.name==='fatal').length,1);
});
for(const connectionEvent of [{type:'close'},{type:'reconnecting',server:'ws://agent'}] as const){
test(`real runtime ${connectionEvent.type} cancels a pending scene without late room input`,async()=>{
const setup=await runtimeWithPendingRoomMount();
setup.wire.emit(connectionEvent);assert.throws(()=>setup.hosts[0]!.getSnapshot(),/disposed/);
setup.mount.resolve();await settleRoomMount();
assert.deepEqual(setup.events,['attach','mount','dispose','view:release']);assert.equal(setup.clock.timers.size,0);
assert.equal(setup.scene.calls.filter(call=>call.name==='fatal').length,0);setup.runtime.stop();
});
}
test('slow mount queues full ready packets so each event sees its own committed player state',async()=>{
const mount=deferred<void>(),seen:boolean[][]=[],events:string[]=[];let host:GameHost;
const setup=makeRuntime(runtimeConfig(),makeGameEntry({createModule:()=>({
attach(value){host=value;},handlePlatformEvent(event){
events.push(event.type);
if(event.type==='room.player-ready'){
seen.push(host.getSnapshot().seats.map(seat=>seat.ready));
setup.runtime.prepare();
}
},handleGameMessage(){events.push('game');},restore(){events.push('restore');},dispose(){},
})}));
const port:ScenePort=setup.scene;port.showRoom=()=>mount.promise;
await setup.runtime.start();setup.wire.emit({type:'open',server:'ws://agent'});setup.runtime.login(ACCOUNT);
const login=fixture('player-login-room.json'),players=login.players as Array<Record<string,unknown>|null>;
players[1]!.isprepare=0;players[2]={...players[1],playerid:430512,nickname:'同桌玩家'};
setup.wire.emit({type:'message',message:{route:'agent',rpc:'player_login',data:login}});
const pendingState=setup.runtime.state;
setup.wire.emit({type:'message',message:{route:'room',rpc:'player_prepare',data:{seat:1}}});
setup.wire.emit({type:'message',message:{route:'room',rpc:'player_prepare',data:{seat:2}}});
assert.equal(setup.runtime.state,pendingState);assert.deepEqual(events,[]);assert.deepEqual(seen,[]);
mount.resolve();await settleRoomMount();
assert.deepEqual(events,['room.entered','restore','room.player-ready','room.player-ready']);
assert.deepEqual(seen,[[true,false],[true,true]]);
assert.equal(setup.wire.sent.filter(message=>message.rpc==='player_prepare').length,2);
assert.equal(setup.scene.calls.filter(call=>call.name==='fatal').length,0);setup.runtime.stop();
});
test('login replacement bypasses pending room packets and clears their old generation',async()=>{
const setup=await runtimeWithPendingRoomMount();
setup.wire.emit({type:'message',message:{route:'room',rpc:'player_prepare',data:{seat:1}}});
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.clock.timers.size,0);
setup.mount.resolve();await settleRoomMount();
assert.deepEqual(setup.events,['attach','mount','dispose','view:release']);assert.equal(setup.runtime.state.room.kind,'outside');
assert.equal(setup.scene.calls.filter(call=>call.name==='fatal').length,0);setup.runtime.stop();
});
test('authentication barrier ignores room packets at receipt even while an earlier room is mounting',async()=>{
const setup=await runtimeWithPendingRoomMount();
setup.runtime.login(ACCOUNT);
setup.wire.emit({type:'message',message:{route:'fixture-game',rpc:'ignored-during-login',data:{}}});
setup.mount.resolve();await settleRoomMount();
assert.equal(setup.events.includes('game:ignored-during-login'),false);
assert.equal(setup.scene.calls.filter(call=>call.name==='fatal').length,0);setup.runtime.stop();
});
test('one hundred real runtime room entries and lobby exits return modules, leases, subscriptions and timers to baseline',async()=>{
const hosts:GameHost[]=[];let created=0,attached=0,disposed=0,subscriptions=0;
const originalSubscribe=PlatformStore.prototype.subscribe;
PlatformStore.prototype.subscribe=function(listener){
const release=originalSubscribe.call(this,listener);subscriptions++;let live=true;
return ()=>{if(!live)return;live=false;subscriptions--;release();};
};
const setup=makeRuntime(runtimeConfig(),makeGameEntry({createModule(){
created++;
let active=false,release:(()=>void)|null=null;
return {attach(host){active=true;attached++;hosts.push(host);release=host.subscribe(()=>{});},
handlePlatformEvent(){},handleGameMessage(){},restore(){},dispose(){if(active){active=false;disposed++;release?.();}}};
}}));
try{
await setup.runtime.start();assert.equal(created,0);setup.wire.emit({type:'open',server:'ws://agent'});
const baselineSubscriptions=subscriptions,baselineTimers=setup.clock.timers.size;
for(let cycle=0;cycle<100;cycle++){
setup.runtime.login(ACCOUNT);
setup.wire.emit({type:'message',message:{route:'agent',rpc:'player_login',data:fixture('player-login-room.json')}});
assert.equal(created,cycle+1);assert.equal(attached,cycle+1);assert.equal(disposed,cycle);
assert.equal(hosts.filter(host=>host.capabilities?.scope.active).length,1);
setup.wire.emit({type:'message',message:{route:'agent',rpc:'player_login',data:fixture('player-login-success.json')}});
assert.equal(disposed,cycle+1);assert.equal(setup.runtime.state.room.kind,'outside');
assert.equal(hosts.filter(host=>host.capabilities?.scope.active).length,0);
assert.equal(subscriptions,baselineSubscriptions);assert.equal(setup.clock.timers.size,baselineTimers);
}
assert.equal(setup.scene.calls.filter(call=>call.name==='fatal').length,0);
}finally{setup.runtime.stop();PlatformStore.prototype.subscribe=originalSubscribe;}
assert.equal(subscriptions,0);assert.equal(setup.clock.timers.size,0);assert.equal(created,100);assert.equal(attached,100);assert.equal(disposed,100);
});