1398 lines
45 KiB
TypeScript
1398 lines
45 KiB
TypeScript
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';
|
|
|
|
function fixture(name: string): Record<string, unknown> {
|
|
return JSON.parse(
|
|
readFileSync(new URL(`../fixtures/contracts/${name}`, import.meta.url), 'utf8'),
|
|
).data;
|
|
}
|
|
|
|
const ACCOUNT = Object.freeze({
|
|
openid: 'o1',
|
|
nickname: '测试号',
|
|
avatar: 'http://a',
|
|
sex: 0,
|
|
province: '江西',
|
|
city: '南昌',
|
|
unionid: 'test_unionid',
|
|
});
|
|
|
|
const DEVICE = Object.freeze({
|
|
ip: '127.0.0.1',
|
|
location: Object.freeze({ latitude: 1, longitude: 2 }),
|
|
machineid: 'machine-1',
|
|
machineroom: 'room-install-1',
|
|
deviceLogin: Object.freeze({ enabled: false as const }),
|
|
loginPlayerId: Object.freeze({ enabled: false as const }),
|
|
});
|
|
|
|
function runtimeConfig(gameid: string | number = '41'): RuntimeConfig {
|
|
return Object.freeze({
|
|
mode: 'debug',
|
|
isDebugger: true,
|
|
identity: Object.freeze({
|
|
agentid: 'veRa0qrBf',
|
|
channelid: 'FtJf073aa',
|
|
gameid,
|
|
marketid: 4,
|
|
version: 10000,
|
|
}),
|
|
servers: Object.freeze(['ws://agent']),
|
|
source: 'direct',
|
|
gameserver: null,
|
|
rawConfig: null,
|
|
}) as RuntimeConfig;
|
|
}
|
|
|
|
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); }
|
|
showFatal(error: Error): void { this.record('fatal', error); }
|
|
}
|
|
|
|
function makeGameEntry(overrides: Partial<GameEntry> = {}): GameEntry {
|
|
return {
|
|
key: 'fixture',
|
|
gameId: '41',
|
|
route: 'fixture-game',
|
|
resolveSeatCount: () => 4,
|
|
createModule: (): GameModule => ({
|
|
attach() {},
|
|
handlePlatformEvent() {},
|
|
handleGameMessage() {},
|
|
restore() {},
|
|
dispose() {},
|
|
}),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function makeRuntime(config: RuntimeConfig = runtimeConfig(), entry = makeGameEntry()) {
|
|
const wire = new RecordingWireClient();
|
|
const scene = new RecordingScene();
|
|
const clock = new ManualClock();
|
|
const factoryConfigs: RuntimeConfig[] = [];
|
|
const runtime = new PlatformRuntime({
|
|
gameEntry: entry,
|
|
resolveRuntimeConfig: async () => config,
|
|
createWireClient(resolved) {
|
|
factoryConfigs.push(resolved);
|
|
return wire;
|
|
},
|
|
scene,
|
|
loadResources: async () => {},
|
|
waitForMinimumDisplay: async () => {},
|
|
getLoginDeviceSnapshot: () => DEVICE,
|
|
clock,
|
|
});
|
|
return { runtime, wire, scene, clock, factoryConfigs, config };
|
|
}
|
|
|
|
function makeLoggingEntry(log: string[], restored: unknown[], attached: GameHost[]): GameEntry {
|
|
let recording = false;
|
|
const entry = makeGameEntry({
|
|
createModule: (): GameModule => {
|
|
let host: GameHost | null = null;
|
|
return {
|
|
attach(value) {
|
|
host = value;
|
|
if (!recording) return;
|
|
attached.push(value);
|
|
log.push(`attach:${value.getSnapshot().room.roomcode}`);
|
|
},
|
|
handlePlatformEvent(event: PlatformToGameEvent) {
|
|
if (recording) log.push(`event:${event.type}`);
|
|
},
|
|
handleGameMessage() {},
|
|
restore(deskinfo) {
|
|
if (!recording) return;
|
|
assert.ok(host);
|
|
restored.push(deskinfo);
|
|
log.push('restore');
|
|
},
|
|
dispose() {
|
|
if (!recording) return;
|
|
assert.ok(host);
|
|
host.getSnapshot();
|
|
log.push('dispose');
|
|
},
|
|
};
|
|
},
|
|
});
|
|
Object.defineProperty(entry, 'beginRecording', {
|
|
value: () => { recording = true; },
|
|
});
|
|
return entry;
|
|
}
|
|
|
|
function beginEntryRecording(entry: GameEntry): void {
|
|
(entry as GameEntry & { beginRecording(): void }).beginRecording();
|
|
}
|
|
|
|
interface CapturedGameHost {
|
|
host: GameHost | null;
|
|
}
|
|
|
|
function makeCleanupFailureEntry(
|
|
log: string[],
|
|
disposeFailure: unknown,
|
|
captured: CapturedGameHost,
|
|
): GameEntry {
|
|
let moduleNumber = 0;
|
|
return makeGameEntry({
|
|
createModule(): GameModule {
|
|
moduleNumber += 1;
|
|
const validationModule = moduleNumber === 1;
|
|
return {
|
|
attach(host) {
|
|
if (validationModule) return;
|
|
captured.host = host;
|
|
host.subscribe(() => {});
|
|
},
|
|
handlePlatformEvent() {},
|
|
handleGameMessage() {},
|
|
restore() {},
|
|
dispose() {
|
|
if (validationModule) return;
|
|
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 moduleNumber = 0;
|
|
let activeHost: GameHost | null = null;
|
|
const entry = makeGameEntry({
|
|
createModule(): GameModule {
|
|
moduleNumber += 1;
|
|
const currentModule = moduleNumber;
|
|
return {
|
|
attach(host) {
|
|
if (currentModule === 1) return;
|
|
activeHost = host;
|
|
host.subscribe(() => {});
|
|
},
|
|
handlePlatformEvent() {},
|
|
handleGameMessage() {},
|
|
restore() {},
|
|
dispose() {
|
|
if (currentModule === 1) return;
|
|
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: 'free_room',
|
|
nested: { trace: 'a' },
|
|
},
|
|
},
|
|
] as const) {
|
|
test(`${switchCase.label} switch resends the exact switch envelope before returning to steady login`, async () => {
|
|
const setup = makeRuntime();
|
|
await setup.runtime.start();
|
|
setup.wire.emit({ type: 'open', server: 'ws://agent' });
|
|
setup.runtime.login(ACCOUNT);
|
|
const loginEnvelope = setup.wire.sent[0]!;
|
|
setup.wire.emit({
|
|
type: 'message',
|
|
message: { route: 'agent', rpc: 'player_login', data: fixture('player-login-success.json') },
|
|
});
|
|
setup.wire.sent.length = 0;
|
|
|
|
setup.wire.emit({
|
|
type: 'message',
|
|
message: { route: switchCase.route, rpc: switchCase.rpc, data: switchCase.data },
|
|
});
|
|
|
|
assert.deepEqual(setup.wire.switchCalls, [switchCase.target]);
|
|
assert.equal(setup.wire.sent.length, 0);
|
|
|
|
setup.wire.emit({ type: 'open', server: switchCase.target });
|
|
|
|
assert.equal(setup.wire.sent.length, 1);
|
|
assert.equal(setup.wire.sent[0]?.rpc, switchCase.rpc);
|
|
assert.deepEqual(setup.wire.sent[0], {
|
|
app: 'youle',
|
|
route: switchCase.route,
|
|
rpc: switchCase.rpc,
|
|
data: switchCase.data,
|
|
});
|
|
assert.equal(setup.wire.sent[0]?.data, switchCase.data);
|
|
assert.equal(setup.clock.timers.size, 0, 'switch resend must not start the login guard');
|
|
|
|
setup.wire.emit({ type: 'open', server: switchCase.target });
|
|
|
|
assert.equal(setup.wire.sent.length, 2);
|
|
assert.equal(setup.wire.sent[1]?.rpc, 'player_login');
|
|
assert.equal(setup.wire.sent[1]?.data, loginEnvelope.data);
|
|
assert.deepEqual([...setup.clock.timers.values()].map((timer) => timer.ms), [4000]);
|
|
});
|
|
}
|
|
|
|
test('successful login without a room shows the lobby', async () => {
|
|
const setup = makeRuntime();
|
|
await setup.runtime.start();
|
|
setup.wire.emit({ type: 'open', server: 'ws://agent' });
|
|
setup.runtime.login(ACCOUNT);
|
|
|
|
setup.wire.emit({
|
|
type: 'message',
|
|
message: { route: 'agent', rpc: 'player_login', data: fixture('player-login-success.json') },
|
|
});
|
|
|
|
assert.equal(setup.runtime.state.room.kind, 'outside');
|
|
assert.equal(setup.scene.calls[setup.scene.calls.length - 1]?.name, 'lobby');
|
|
});
|
|
|
|
test('room login commits and opens before showRoom, then restores the exact deskinfo', async () => {
|
|
const log: string[] = [];
|
|
const restored: unknown[] = [];
|
|
const attached: GameHost[] = [];
|
|
const entry = makeLoggingEntry(log, restored, attached);
|
|
const setup = makeRuntime(runtimeConfig(), entry);
|
|
setup.scene.onCall = (name) => { if (name === 'room') log.push('scene:room'); };
|
|
await setup.runtime.start();
|
|
beginEntryRecording(entry);
|
|
setup.wire.emit({ type: 'open', server: 'ws://agent' });
|
|
setup.runtime.login(ACCOUNT);
|
|
const roomLogin = fixture('player-login-room.json');
|
|
|
|
setup.wire.emit({
|
|
type: 'message',
|
|
message: { route: 'agent', rpc: 'player_login', data: roomLogin },
|
|
});
|
|
|
|
assert.equal(setup.runtime.state.room.kind, 'inside');
|
|
assert.deepEqual(log, [
|
|
'attach:100001',
|
|
'event:room.entered',
|
|
'scene:room',
|
|
'restore',
|
|
]);
|
|
assert.deepEqual(restored, [roomLogin.deskinfo]);
|
|
assert.equal(restored[0], roomLogin.deskinfo);
|
|
assert.equal(attached.length, 1);
|
|
});
|
|
|
|
for (const withDeskinfo of [false, true]) {
|
|
test(`self join opens and shows the room ${withDeskinfo ? 'before restoring deskinfo' : 'without restoring absent deskinfo'}`, async () => {
|
|
const log: string[] = [];
|
|
const restored: unknown[] = [];
|
|
const attached: GameHost[] = [];
|
|
const entry = makeLoggingEntry(log, restored, attached);
|
|
const setup = makeRuntime(runtimeConfig(), entry);
|
|
setup.scene.onCall = (name) => { if (name === 'room') log.push('scene:room'); };
|
|
await setup.runtime.start();
|
|
beginEntryRecording(entry);
|
|
setup.wire.emit({ type: 'open', server: 'ws://agent' });
|
|
setup.runtime.login(ACCOUNT);
|
|
setup.wire.emit({
|
|
type: 'message',
|
|
message: { route: 'agent', rpc: 'player_login', data: fixture('player-login-success.json') },
|
|
});
|
|
log.length = 0;
|
|
const join = fixture('self-join-room.json');
|
|
const deskinfo = join.deskinfo;
|
|
if (!withDeskinfo) delete join.deskinfo;
|
|
|
|
setup.wire.emit({
|
|
type: 'message',
|
|
message: { route: 'agent', rpc: 'self_join_room', data: join },
|
|
});
|
|
|
|
assert.deepEqual(log, withDeskinfo
|
|
? ['attach:100001', 'event:room.entered', 'scene:room', 'restore']
|
|
: ['attach:100001', 'event:room.entered', 'scene:room']);
|
|
assert.deepEqual(restored, withDeskinfo ? [deskinfo] : []);
|
|
if (withDeskinfo) assert.equal(restored[0], deskinfo);
|
|
});
|
|
}
|
|
|
|
test('slow and reconnecting show reconnect without clearing or replacing room state', async () => {
|
|
const setup = makeRuntime();
|
|
await setup.runtime.start();
|
|
setup.wire.emit({ type: 'open', server: 'ws://agent' });
|
|
setup.runtime.login(ACCOUNT);
|
|
setup.wire.emit({
|
|
type: 'message',
|
|
message: { route: 'agent', rpc: 'player_login', data: fixture('player-login-room.json') },
|
|
});
|
|
const roomState = setup.runtime.state;
|
|
const callsBefore = setup.scene.calls.length;
|
|
|
|
setup.wire.emit({ type: 'slow' });
|
|
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 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') },
|
|
});
|
|
|
|
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');
|
|
delete joinedRoom.deskinfo;
|
|
joinedRoom.deskwar = 0;
|
|
setup.wire.emit({
|
|
type: 'message',
|
|
message: { route: 'agent', rpc: 'self_join_room', data: joinedRoom },
|
|
});
|
|
setup.wire.sent.length = 0;
|
|
setup.runtime.prepare();
|
|
setup.runtime.exitRoom();
|
|
|
|
assert.deepEqual(setup.wire.sent, [
|
|
{
|
|
app: 'youle',
|
|
route: 'room',
|
|
rpc: 'player_prepare',
|
|
data: {
|
|
agentid: 'veRa0qrBf', playerid: 430511, gameid: '41', roomcode: '100001',
|
|
},
|
|
},
|
|
{
|
|
app: 'youle',
|
|
route: 'room',
|
|
rpc: 'self_exit_room',
|
|
data: {
|
|
agentid: 'veRa0qrBf', playerid: 430511, gameid: '41', roomcode: '100001',
|
|
},
|
|
},
|
|
]);
|
|
});
|
|
|
|
test('stop unsubscribes, disposes while the host is live, invalidates it, then stops the wire', async () => {
|
|
const log: string[] = [];
|
|
const restored: unknown[] = [];
|
|
const attached: GameHost[] = [];
|
|
const entry = makeLoggingEntry(log, restored, attached);
|
|
const setup = makeRuntime(runtimeConfig(), entry);
|
|
setup.wire.onAction = (name) => {
|
|
if (name === 'unsubscribe' || name === 'stop') log.push(`wire:${name}`);
|
|
};
|
|
await setup.runtime.start();
|
|
beginEntryRecording(entry);
|
|
setup.wire.emit({ type: 'open', server: 'ws://agent' });
|
|
setup.runtime.login(ACCOUNT);
|
|
setup.wire.emit({
|
|
type: 'message',
|
|
message: { route: 'agent', rpc: 'player_login', data: fixture('player-login-room.json') },
|
|
});
|
|
const activeHost = attached[0]!;
|
|
log.length = 0;
|
|
|
|
setup.runtime.stop();
|
|
|
|
assert.deepEqual(log, ['wire:unsubscribe', 'dispose', 'wire:stop']);
|
|
assert.throws(() => activeHost.getSnapshot(), /disposed/i);
|
|
setup.runtime.stop();
|
|
assert.deepEqual(log, ['wire:unsubscribe', 'dispose', 'wire:stop']);
|
|
assert.equal(setup.wire.stopCalls, 1);
|
|
});
|
|
|
|
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',
|
|
'game:dispose',
|
|
'lease:invalidate',
|
|
'wire:stop',
|
|
]);
|
|
assert.equal(errors.length, 5);
|
|
assert.equal(errors[0], clearFailure);
|
|
assert.equal(errors[1], undefined);
|
|
assert.equal(errors[2], disposeFailure);
|
|
assert.equal(errors[3], leaseFailure);
|
|
assert.equal(errors[4], 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, [
|
|
'game:dispose',
|
|
'lease:invalidate',
|
|
'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, 3);
|
|
assert.equal(sessionErrors[0], disposeFailure);
|
|
assert.equal(sessionErrors[1], leaseFailure);
|
|
const kickCleanup = sessionErrors[2];
|
|
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;
|
|
}
|
|
});
|