feat(platform): add single-entry platform runtime
This commit is contained in:
@@ -0,0 +1,25 @@
|
|||||||
|
export type ReadyGateName =
|
||||||
|
| 'resources'
|
||||||
|
| 'config'
|
||||||
|
| 'socket'
|
||||||
|
| 'minimum-display';
|
||||||
|
|
||||||
|
const REQUIRED_GATES: readonly ReadyGateName[] = Object.freeze([
|
||||||
|
'resources',
|
||||||
|
'config',
|
||||||
|
'socket',
|
||||||
|
'minimum-display',
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** One-way startup latch: readiness is reached only after all four signals arrive. */
|
||||||
|
export class ReadyGate {
|
||||||
|
private readonly marked = new Set<ReadyGateName>();
|
||||||
|
|
||||||
|
get ready(): boolean {
|
||||||
|
return REQUIRED_GATES.every((name) => this.marked.has(name));
|
||||||
|
}
|
||||||
|
|
||||||
|
mark(name: ReadyGateName): void {
|
||||||
|
this.marked.add(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,504 @@
|
|||||||
|
import type { RuntimeConfig } from '../config/runtime-config.ts';
|
||||||
|
import { LOGIN_GUARD_MS } from '../core/constants.ts';
|
||||||
|
import type { OutboundEnvelope } from '../core/types/envelope.ts';
|
||||||
|
import { realClock, type Clock } from '../net/heartbeat.ts';
|
||||||
|
import type { WireClient, WireEvent } from '../net/wire-client.ts';
|
||||||
|
import {
|
||||||
|
buildLoginRequest,
|
||||||
|
type LoginAccountIdentity,
|
||||||
|
type LoginDeviceSnapshot,
|
||||||
|
} from '../protocol/contracts/index.ts';
|
||||||
|
import { PlatformHandlers } from '../protocol/platform-handlers.ts';
|
||||||
|
import { Router } from '../protocol/router.ts';
|
||||||
|
import type { GameEntry, GameHost } from '../sdk/contracts/index.ts';
|
||||||
|
import { GameSessionHost } from '../sdk/runtime/game-session-host.ts';
|
||||||
|
import { PlatformCommands, type JoinRoomCommand } from './commands.ts';
|
||||||
|
import type { ConnectionIntent } from './connection-intent.ts';
|
||||||
|
import {
|
||||||
|
createGameHostAdapter,
|
||||||
|
type GameHostLease,
|
||||||
|
} from './game-host-adapter.ts';
|
||||||
|
import { RuntimeSession } from './runtime-session.ts';
|
||||||
|
import type { ScenePort } from './scene-port.ts';
|
||||||
|
import { PlatformStore } from './stores/platform-store.ts';
|
||||||
|
import type { PlatformState } from './stores/platform-types.ts';
|
||||||
|
import { ReadyGate, type ReadyGateName } from './ready-gate.ts';
|
||||||
|
|
||||||
|
export type PlatformWireClient = Pick<
|
||||||
|
WireClient,
|
||||||
|
'subscribe' | 'start' | 'send' | 'switchServer' | 'reconnectCurrent' | 'stop'
|
||||||
|
>;
|
||||||
|
|
||||||
|
export interface PlatformRuntimeOptions {
|
||||||
|
readonly gameEntry: GameEntry;
|
||||||
|
readonly resolveRuntimeConfig: () => Promise<RuntimeConfig>;
|
||||||
|
readonly createWireClient: (config: RuntimeConfig) => PlatformWireClient;
|
||||||
|
readonly scene: ScenePort;
|
||||||
|
readonly loadResources: () => Promise<void>;
|
||||||
|
readonly waitForMinimumDisplay: () => Promise<void>;
|
||||||
|
readonly getLoginDeviceSnapshot: () => LoginDeviceSnapshot;
|
||||||
|
readonly clock?: Clock;
|
||||||
|
}
|
||||||
|
|
||||||
|
type RuntimeLifecycle = 'idle' | 'starting' | 'running' | 'kicked' | 'fatal' | 'stopped';
|
||||||
|
|
||||||
|
/** Keeps the scene transition between room activation and optional snapshot restore. */
|
||||||
|
class SceneOrderedGameSessionHost extends GameSessionHost {
|
||||||
|
private roomSceneShown = false;
|
||||||
|
|
||||||
|
constructor(entry: GameEntry, private readonly scene: ScenePort) {
|
||||||
|
super(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
open(host: GameHost): void {
|
||||||
|
this.roomSceneShown = false;
|
||||||
|
super.open(host);
|
||||||
|
}
|
||||||
|
|
||||||
|
restore(deskinfo: unknown): void {
|
||||||
|
this.showRoomAfterOpen();
|
||||||
|
super.restore(deskinfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
showRoomAfterOpen(): void {
|
||||||
|
if (this.roomSceneShown) return;
|
||||||
|
if (this.state !== 'active') {
|
||||||
|
throw new Error('Cannot show the room without an active game session');
|
||||||
|
}
|
||||||
|
this.roomSceneShown = true;
|
||||||
|
this.scene.showRoom();
|
||||||
|
}
|
||||||
|
|
||||||
|
close(): void {
|
||||||
|
try {
|
||||||
|
super.close();
|
||||||
|
} finally {
|
||||||
|
this.roomSceneShown = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose(): void {
|
||||||
|
try {
|
||||||
|
super.dispose();
|
||||||
|
} finally {
|
||||||
|
this.roomSceneShown = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class PlatformRuntimeCleanupError extends Error {
|
||||||
|
readonly errors: readonly unknown[];
|
||||||
|
|
||||||
|
constructor(operation: string, errors: readonly unknown[]) {
|
||||||
|
super(`PlatformRuntime cleanup failed during ${operation}`);
|
||||||
|
this.name = 'PlatformRuntimeCleanupError';
|
||||||
|
this.errors = Object.freeze([...errors]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Owns the sole headless config, wire, router, store, and game-session composition. */
|
||||||
|
export class PlatformRuntime {
|
||||||
|
private readonly store = new PlatformStore();
|
||||||
|
private readonly gate = new ReadyGate();
|
||||||
|
private readonly clock: Clock;
|
||||||
|
private lifecycle: RuntimeLifecycle = 'idle';
|
||||||
|
private config: RuntimeConfig | null = null;
|
||||||
|
private wire: PlatformWireClient | null = null;
|
||||||
|
private unsubscribeWire: (() => void) | null = null;
|
||||||
|
private session: RuntimeSession | null = null;
|
||||||
|
private router: Router | null = null;
|
||||||
|
private gameSession: SceneOrderedGameSessionHost | null = null;
|
||||||
|
private commands: PlatformCommands | null = null;
|
||||||
|
private activeGameHostLease: GameHostLease | null = null;
|
||||||
|
private connectionIntent: ConnectionIntent = { type: 'none' };
|
||||||
|
private loginEnvelope: OutboundEnvelope | null = null;
|
||||||
|
private loginGuard: unknown = null;
|
||||||
|
private wireIsOpen = false;
|
||||||
|
private loginSceneShown = false;
|
||||||
|
private fatalError: Error | null = null;
|
||||||
|
private wireStopped = false;
|
||||||
|
|
||||||
|
constructor(private readonly options: PlatformRuntimeOptions) {
|
||||||
|
this.clock = options.clock ?? realClock;
|
||||||
|
}
|
||||||
|
|
||||||
|
get state(): PlatformState {
|
||||||
|
return this.store.getState();
|
||||||
|
}
|
||||||
|
|
||||||
|
get ready(): boolean {
|
||||||
|
return this.gate.ready;
|
||||||
|
}
|
||||||
|
|
||||||
|
async start(): Promise<void> {
|
||||||
|
if (this.lifecycle !== 'idle') {
|
||||||
|
throw new Error(`PlatformRuntime cannot start while ${this.lifecycle}`);
|
||||||
|
}
|
||||||
|
this.lifecycle = 'starting';
|
||||||
|
this.options.scene.showLoading();
|
||||||
|
const resourcesReady = this.beginReadyTask('resources', this.options.loadResources);
|
||||||
|
const minimumDisplayReady = this.beginReadyTask(
|
||||||
|
'minimum-display',
|
||||||
|
this.options.waitForMinimumDisplay,
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const config = await this.options.resolveRuntimeConfig();
|
||||||
|
if (this.fatalError !== null) throw this.fatalError;
|
||||||
|
if (config.identity.gameid !== this.options.gameEntry.gameId) {
|
||||||
|
throw new Error(
|
||||||
|
'RuntimeConfig identity.gameid must strictly equal GameEntry.gameId: '
|
||||||
|
+ `config=${String(config.identity.gameid)} (${typeof config.identity.gameid}), `
|
||||||
|
+ `entry=${String(this.options.gameEntry.gameId)} (${typeof this.options.gameEntry.gameId})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this.config = config;
|
||||||
|
|
||||||
|
const gameSession = new SceneOrderedGameSessionHost(
|
||||||
|
this.options.gameEntry,
|
||||||
|
this.options.scene,
|
||||||
|
);
|
||||||
|
const send = Object.freeze({
|
||||||
|
send: (envelope: OutboundEnvelope): void => {
|
||||||
|
this.requireOpenWire().send(envelope);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const commands = new PlatformCommands({
|
||||||
|
runtimeConfig: config,
|
||||||
|
store: this.store,
|
||||||
|
send,
|
||||||
|
});
|
||||||
|
const session = new RuntimeSession({
|
||||||
|
store: this.store,
|
||||||
|
gameSession,
|
||||||
|
createGameHostLease: () => this.createGameHostLease(commands, send),
|
||||||
|
});
|
||||||
|
const handlers = new PlatformHandlers({
|
||||||
|
store: this.store,
|
||||||
|
gameSession,
|
||||||
|
session,
|
||||||
|
connection: {
|
||||||
|
switchServer: (target, resend) => { this.beginServerSwitch(target, resend); },
|
||||||
|
stopForKick: (data) => { this.stopForKick(data); },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const router = new Router({ session, platform: handlers, gameSession });
|
||||||
|
const wire = this.options.createWireClient(config);
|
||||||
|
|
||||||
|
this.gameSession = gameSession;
|
||||||
|
this.commands = commands;
|
||||||
|
this.session = session;
|
||||||
|
this.router = router;
|
||||||
|
this.wire = wire;
|
||||||
|
this.unsubscribeWire = wire.subscribe((event) => { this.onWireEvent(event); });
|
||||||
|
this.markReady('config');
|
||||||
|
this.lifecycle = 'running';
|
||||||
|
wire.start();
|
||||||
|
await Promise.all([resourcesReady, minimumDisplayReady]);
|
||||||
|
if (this.fatalError !== null) throw this.fatalError;
|
||||||
|
} catch (error) {
|
||||||
|
const fatal = this.showFatalOnce(error);
|
||||||
|
throw fatal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
login(account: LoginAccountIdentity): void {
|
||||||
|
const config = this.requireConfig();
|
||||||
|
const wire = this.requireOpenWire();
|
||||||
|
const session = this.requireSession();
|
||||||
|
const envelope = buildLoginRequest(
|
||||||
|
config,
|
||||||
|
account,
|
||||||
|
this.options.getLoginDeviceSnapshot(),
|
||||||
|
);
|
||||||
|
|
||||||
|
wire.send(envelope);
|
||||||
|
this.loginEnvelope = envelope;
|
||||||
|
this.connectionIntent = { type: 'login', envelope };
|
||||||
|
session.markLoginPending();
|
||||||
|
this.startLoginGuard();
|
||||||
|
}
|
||||||
|
|
||||||
|
joinRoom(command: JoinRoomCommand): void {
|
||||||
|
this.requireCommands().joinRoom(command);
|
||||||
|
}
|
||||||
|
|
||||||
|
prepare(): void {
|
||||||
|
this.requireCommands().prepare();
|
||||||
|
}
|
||||||
|
|
||||||
|
exitRoom(): void {
|
||||||
|
this.requireCommands().exitRoom();
|
||||||
|
}
|
||||||
|
|
||||||
|
stop(): void {
|
||||||
|
if (this.lifecycle === 'stopped') return;
|
||||||
|
this.lifecycle = 'stopped';
|
||||||
|
this.clearLoginGuard();
|
||||||
|
this.connectionIntent = { type: 'none' };
|
||||||
|
this.wireIsOpen = false;
|
||||||
|
|
||||||
|
const errors: unknown[] = [];
|
||||||
|
const unsubscribe = this.unsubscribeWire;
|
||||||
|
this.unsubscribeWire = null;
|
||||||
|
if (unsubscribe !== null) this.captureCleanupError(errors, unsubscribe);
|
||||||
|
|
||||||
|
const gameSession = this.gameSession;
|
||||||
|
if (gameSession !== null) {
|
||||||
|
this.captureCleanupError(errors, () => { gameSession.dispose(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
const lease = this.activeGameHostLease;
|
||||||
|
this.activeGameHostLease = null;
|
||||||
|
if (lease !== null) {
|
||||||
|
this.captureCleanupError(errors, () => { lease.invalidate(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
this.captureCleanupError(errors, () => { this.stopWireOnce(); });
|
||||||
|
this.throwCleanupErrors('stop', errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
private beginReadyTask(
|
||||||
|
name: ReadyGateName,
|
||||||
|
task: () => Promise<void>,
|
||||||
|
): Promise<void> {
|
||||||
|
return Promise.resolve()
|
||||||
|
.then(task)
|
||||||
|
.then(
|
||||||
|
() => { this.markReady(name); },
|
||||||
|
(error: unknown) => { this.showFatalOnce(error); },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private markReady(name: ReadyGateName): void {
|
||||||
|
if (
|
||||||
|
this.lifecycle === 'fatal'
|
||||||
|
|| this.lifecycle === 'kicked'
|
||||||
|
|| this.lifecycle === 'stopped'
|
||||||
|
) return;
|
||||||
|
this.gate.mark(name);
|
||||||
|
if (this.gate.ready && !this.loginSceneShown) {
|
||||||
|
this.loginSceneShown = true;
|
||||||
|
this.options.scene.showLogin();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private onWireEvent(event: WireEvent): void {
|
||||||
|
if (
|
||||||
|
this.lifecycle === 'fatal'
|
||||||
|
|| this.lifecycle === 'kicked'
|
||||||
|
|| this.lifecycle === 'stopped'
|
||||||
|
) return;
|
||||||
|
try {
|
||||||
|
switch (event.type) {
|
||||||
|
case 'open':
|
||||||
|
this.wireIsOpen = true;
|
||||||
|
this.markReady('socket');
|
||||||
|
if (this.connectionIntent.type === 'switch') {
|
||||||
|
const loginEnvelope = this.loginEnvelope;
|
||||||
|
if (loginEnvelope === null) {
|
||||||
|
throw new Error('server switch requires a previously sent player_login envelope');
|
||||||
|
}
|
||||||
|
this.requireOpenWire().send(this.connectionIntent.resend);
|
||||||
|
this.connectionIntent = { type: 'login', envelope: loginEnvelope };
|
||||||
|
} else if (this.connectionIntent.type === 'login') {
|
||||||
|
this.sendLoginEnvelope(this.connectionIntent.envelope);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'message':
|
||||||
|
if (event.message.route === 'agent' && event.message.rpc === 'player_login') {
|
||||||
|
this.clearLoginGuard();
|
||||||
|
}
|
||||||
|
this.requireRouter().dispatch(event.message);
|
||||||
|
this.updateSceneAfterMessage(event.message.route, event.message.rpc);
|
||||||
|
break;
|
||||||
|
case 'slow':
|
||||||
|
case 'reconnecting':
|
||||||
|
this.wireIsOpen = false;
|
||||||
|
this.options.scene.showReconnect();
|
||||||
|
break;
|
||||||
|
case 'close':
|
||||||
|
this.wireIsOpen = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.showFatalOnce(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sendLoginEnvelope(envelope: OutboundEnvelope): void {
|
||||||
|
const wire = this.requireOpenWire();
|
||||||
|
const session = this.requireSession();
|
||||||
|
wire.send(envelope);
|
||||||
|
session.markLoginPending();
|
||||||
|
this.startLoginGuard();
|
||||||
|
}
|
||||||
|
|
||||||
|
private beginServerSwitch(target: string, resend: OutboundEnvelope): void {
|
||||||
|
if (this.loginEnvelope === null) {
|
||||||
|
throw new Error('server switch requires a previously sent player_login envelope');
|
||||||
|
}
|
||||||
|
const wire = this.requireWire();
|
||||||
|
this.clearLoginGuard();
|
||||||
|
this.connectionIntent = { type: 'switch', target, resend };
|
||||||
|
this.wireIsOpen = false;
|
||||||
|
wire.switchServer(target);
|
||||||
|
}
|
||||||
|
|
||||||
|
private createGameHostLease(
|
||||||
|
commands: PlatformCommands,
|
||||||
|
send: { send(envelope: OutboundEnvelope): void },
|
||||||
|
): GameHostLease {
|
||||||
|
const lease = createGameHostAdapter({
|
||||||
|
gameEntry: this.options.gameEntry,
|
||||||
|
store: this.store,
|
||||||
|
commands,
|
||||||
|
send,
|
||||||
|
});
|
||||||
|
let invalidated = false;
|
||||||
|
const trackedLease: GameHostLease = Object.freeze({
|
||||||
|
host: lease.host,
|
||||||
|
invalidate: (): void => {
|
||||||
|
if (invalidated) return;
|
||||||
|
invalidated = true;
|
||||||
|
try {
|
||||||
|
lease.invalidate();
|
||||||
|
} finally {
|
||||||
|
if (this.activeGameHostLease === trackedLease) {
|
||||||
|
this.activeGameHostLease = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
this.activeGameHostLease = trackedLease;
|
||||||
|
return trackedLease;
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateSceneAfterMessage(route: string, rpc: string): void {
|
||||||
|
if (route !== 'platform' && route !== 'agent' && route !== 'room') return;
|
||||||
|
if (rpc === 'player_login') {
|
||||||
|
if (this.store.getState().room.kind === 'outside') {
|
||||||
|
this.options.scene.showLobby();
|
||||||
|
} else {
|
||||||
|
this.requireGameSession().showRoomAfterOpen();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (rpc === 'self_join_room') {
|
||||||
|
this.requireGameSession().showRoomAfterOpen();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (rpc === 'self_exit_room') this.options.scene.showLobby();
|
||||||
|
}
|
||||||
|
|
||||||
|
private stopForKick(data: unknown): void {
|
||||||
|
this.lifecycle = 'kicked';
|
||||||
|
this.clearLoginGuard();
|
||||||
|
this.connectionIntent = { type: 'none' };
|
||||||
|
this.wireIsOpen = false;
|
||||||
|
const errors: unknown[] = [];
|
||||||
|
this.captureCleanupError(errors, () => { this.options.scene.showKicked(data); });
|
||||||
|
this.captureCleanupError(errors, () => { this.stopWireOnce(); });
|
||||||
|
this.throwCleanupErrors('kick', errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
private stopWireOnce(): void {
|
||||||
|
if (this.wireStopped) return;
|
||||||
|
const wire = this.wire;
|
||||||
|
if (wire === null) return;
|
||||||
|
this.wireStopped = true;
|
||||||
|
wire.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
private captureCleanupError(errors: unknown[], operation: () => void): void {
|
||||||
|
try {
|
||||||
|
operation();
|
||||||
|
} catch (error) {
|
||||||
|
errors.push(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private throwCleanupErrors(operation: string, errors: readonly unknown[]): void {
|
||||||
|
if (errors.length === 1) throw errors[0];
|
||||||
|
if (errors.length > 1) throw new PlatformRuntimeCleanupError(operation, errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
private startLoginGuard(): void {
|
||||||
|
this.clearLoginGuard();
|
||||||
|
let handle: unknown = null;
|
||||||
|
handle = this.clock.setTimeout(() => {
|
||||||
|
if (this.loginGuard !== handle) return;
|
||||||
|
this.loginGuard = null;
|
||||||
|
try {
|
||||||
|
this.requireWire().reconnectCurrent();
|
||||||
|
} catch (error) {
|
||||||
|
this.showFatalOnce(error);
|
||||||
|
}
|
||||||
|
}, LOGIN_GUARD_MS);
|
||||||
|
this.loginGuard = handle;
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearLoginGuard(): void {
|
||||||
|
if (this.loginGuard === null) return;
|
||||||
|
this.clock.clearTimeout(this.loginGuard);
|
||||||
|
this.loginGuard = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private requireConfig(): RuntimeConfig {
|
||||||
|
this.requireRunning();
|
||||||
|
if (this.config === null) throw new Error('PlatformRuntime has no RuntimeConfig');
|
||||||
|
return this.config;
|
||||||
|
}
|
||||||
|
|
||||||
|
private requireOpenWire(): PlatformWireClient {
|
||||||
|
const wire = this.requireWire();
|
||||||
|
if (!this.wireIsOpen) throw new Error('PlatformRuntime connection is not open');
|
||||||
|
return wire;
|
||||||
|
}
|
||||||
|
|
||||||
|
private requireWire(): PlatformWireClient {
|
||||||
|
this.requireRunning();
|
||||||
|
if (this.wire === null) throw new Error('PlatformRuntime has no WireClient');
|
||||||
|
return this.wire;
|
||||||
|
}
|
||||||
|
|
||||||
|
private requireSession(): RuntimeSession {
|
||||||
|
this.requireRunning();
|
||||||
|
if (this.session === null) throw new Error('PlatformRuntime has no RuntimeSession');
|
||||||
|
return this.session;
|
||||||
|
}
|
||||||
|
|
||||||
|
private requireRouter(): Router {
|
||||||
|
this.requireRunning();
|
||||||
|
if (this.router === null) throw new Error('PlatformRuntime has no Router');
|
||||||
|
return this.router;
|
||||||
|
}
|
||||||
|
|
||||||
|
private requireGameSession(): SceneOrderedGameSessionHost {
|
||||||
|
this.requireRunning();
|
||||||
|
if (this.gameSession === null) throw new Error('PlatformRuntime has no game session');
|
||||||
|
return this.gameSession;
|
||||||
|
}
|
||||||
|
|
||||||
|
private requireCommands(): PlatformCommands {
|
||||||
|
this.requireRunning();
|
||||||
|
if (this.commands === null) throw new Error('PlatformRuntime has no platform commands');
|
||||||
|
return this.commands;
|
||||||
|
}
|
||||||
|
|
||||||
|
private requireRunning(): void {
|
||||||
|
if (this.lifecycle !== 'running') {
|
||||||
|
throw this.fatalError ?? new Error(`PlatformRuntime is ${this.lifecycle}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private showFatalOnce(value: unknown): Error {
|
||||||
|
const error = value instanceof Error ? value : new Error(String(value));
|
||||||
|
if (this.fatalError !== null) return this.fatalError;
|
||||||
|
this.fatalError = error;
|
||||||
|
this.lifecycle = 'fatal';
|
||||||
|
this.clearLoginGuard();
|
||||||
|
this.options.scene.showFatal(error);
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
/** Semantic scene intents; the headless runtime never imports Cocos. */
|
||||||
|
export interface ScenePort {
|
||||||
|
showLoading(): void;
|
||||||
|
showLogin(): void;
|
||||||
|
showLobby(): void;
|
||||||
|
showRoom(): void;
|
||||||
|
showReconnect(): void;
|
||||||
|
showKicked(data: unknown): void;
|
||||||
|
showFatal(error: Error): void;
|
||||||
|
}
|
||||||
@@ -220,6 +220,37 @@ test('compatibility graph applies the same isolation to a future platform/runtim
|
|||||||
assert.equal(found?.path.length, 3);
|
assert.equal(found?.path.length, 3);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('platform/runtime.ts cannot import any former startup owner directly', async () => {
|
||||||
|
const cases = [
|
||||||
|
{ file: 'framework/platform/startup.ts', specifier: './startup.ts' },
|
||||||
|
{ file: 'framework/net/net-client.ts', specifier: '../net/net-client.ts' },
|
||||||
|
{ file: 'framework/platform/session.ts', specifier: './session.ts' },
|
||||||
|
{ file: 'framework/platform/room-rpc-bus.ts', specifier: './room-rpc-bus.ts' },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const item of cases) {
|
||||||
|
const root = await createFixtureRoot();
|
||||||
|
await writeFixture(root, item.file, 'export const legacy = true\n');
|
||||||
|
await writeFixture(
|
||||||
|
root,
|
||||||
|
'framework/platform/runtime.ts',
|
||||||
|
`import '${item.specifier}'\n`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const found = findCompatibilityDependency({
|
||||||
|
entry: join(root, 'framework/platform/runtime.ts'),
|
||||||
|
forbiddenFiles: [join(root, item.file)],
|
||||||
|
traversalRoot: join(root, 'framework'),
|
||||||
|
});
|
||||||
|
assert.equal(found?.kind, 'forbidden', item.file);
|
||||||
|
assert.deepEqual(
|
||||||
|
found?.path.map((file) => file.replaceAll('\\', '/').split('/framework/')[1]),
|
||||||
|
['platform/runtime.ts', item.file.slice('framework/'.length)],
|
||||||
|
item.file,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('scanner rejects sdk contracts importing a framework platform path', async () => {
|
test('scanner rejects sdk contracts importing a framework platform path', async () => {
|
||||||
const root = await createFixtureRoot();
|
const root = await createFixtureRoot();
|
||||||
await writeFixture(root, 'framework/sdk/contracts/bad.ts', "import '../platform/session.ts'\n");
|
await writeFixture(root, 'framework/sdk/contracts/bad.ts', "import '../platform/session.ts'\n");
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import {
|
||||||
|
ReadyGate,
|
||||||
|
type ReadyGateName,
|
||||||
|
} from '../../YouleNexus/assets/framework/platform/ready-gate.ts';
|
||||||
|
|
||||||
|
const GATES = [
|
||||||
|
'resources',
|
||||||
|
'config',
|
||||||
|
'socket',
|
||||||
|
'minimum-display',
|
||||||
|
] as const satisfies readonly ReadyGateName[];
|
||||||
|
|
||||||
|
for (let subset = 0; subset < 16; subset += 1) {
|
||||||
|
test(`ReadyGate is ${subset === 15 ? '' : 'not '}ready for subset ${subset.toString(2).padStart(4, '0')}`, () => {
|
||||||
|
const gate = new ReadyGate();
|
||||||
|
|
||||||
|
GATES.forEach((name, index) => {
|
||||||
|
if ((subset & (1 << index)) !== 0) gate.mark(name);
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(gate.ready, subset === 15);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('ReadyGate duplicate marks are idempotent', () => {
|
||||||
|
const gate = new ReadyGate();
|
||||||
|
|
||||||
|
for (const name of GATES) {
|
||||||
|
gate.mark(name);
|
||||||
|
gate.mark(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.equal(gate.ready, true);
|
||||||
|
});
|
||||||
@@ -0,0 +1,615 @@
|
|||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import type { RuntimeConfig } from '../../YouleNexus/assets/framework/config/runtime-config.ts';
|
||||||
|
import type { OutboundEnvelope } from '../../YouleNexus/assets/framework/core/types/envelope.ts';
|
||||||
|
import type { Clock } from '../../YouleNexus/assets/framework/net/heartbeat.ts';
|
||||||
|
import type { WireEvent } from '../../YouleNexus/assets/framework/net/wire-client.ts';
|
||||||
|
import type {
|
||||||
|
GameEntry,
|
||||||
|
GameHost,
|
||||||
|
GameModule,
|
||||||
|
PlatformToGameEvent,
|
||||||
|
} from '../../YouleNexus/assets/framework/sdk/contracts/index.ts';
|
||||||
|
import {
|
||||||
|
PlatformRuntime,
|
||||||
|
type PlatformWireClient,
|
||||||
|
} from '../../YouleNexus/assets/framework/platform/runtime.ts';
|
||||||
|
import type { ScenePort } from '../../YouleNexus/assets/framework/platform/scene-port.ts';
|
||||||
|
|
||||||
|
function fixture(name: string): Record<string, unknown> {
|
||||||
|
return JSON.parse(
|
||||||
|
readFileSync(new URL(`../fixtures/contracts/${name}`, import.meta.url), 'utf8'),
|
||||||
|
).data;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ACCOUNT = Object.freeze({
|
||||||
|
openid: 'o1',
|
||||||
|
nickname: '测试号',
|
||||||
|
avatar: 'http://a',
|
||||||
|
sex: 0,
|
||||||
|
province: '江西',
|
||||||
|
city: '南昌',
|
||||||
|
unionid: 'test_unionid',
|
||||||
|
});
|
||||||
|
|
||||||
|
const DEVICE = Object.freeze({
|
||||||
|
ip: '127.0.0.1',
|
||||||
|
location: Object.freeze({ latitude: 1, longitude: 2 }),
|
||||||
|
machineid: 'machine-1',
|
||||||
|
machineroom: 'room-install-1',
|
||||||
|
deviceLogin: Object.freeze({ enabled: false as const }),
|
||||||
|
loginPlayerId: Object.freeze({ enabled: false as const }),
|
||||||
|
});
|
||||||
|
|
||||||
|
function runtimeConfig(gameid: string | number = '41'): RuntimeConfig {
|
||||||
|
return Object.freeze({
|
||||||
|
mode: 'debug',
|
||||||
|
isDebugger: true,
|
||||||
|
identity: Object.freeze({
|
||||||
|
agentid: 'veRa0qrBf',
|
||||||
|
channelid: 'FtJf073aa',
|
||||||
|
gameid,
|
||||||
|
marketid: 4,
|
||||||
|
version: 10000,
|
||||||
|
}),
|
||||||
|
servers: Object.freeze(['ws://agent']),
|
||||||
|
source: 'direct',
|
||||||
|
gameserver: null,
|
||||||
|
rawConfig: null,
|
||||||
|
}) as RuntimeConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
class ManualClock implements Clock {
|
||||||
|
private nextHandle = 1;
|
||||||
|
readonly timers = new Map<number, { readonly fn: () => void; readonly ms: number }>();
|
||||||
|
|
||||||
|
setTimeout(fn: () => void, ms: number): unknown {
|
||||||
|
const handle = this.nextHandle;
|
||||||
|
this.nextHandle += 1;
|
||||||
|
this.timers.set(handle, { fn, ms });
|
||||||
|
return handle;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearTimeout(handle: unknown): void {
|
||||||
|
this.timers.delete(handle as number);
|
||||||
|
}
|
||||||
|
|
||||||
|
runOnlyTimer(): void {
|
||||||
|
assert.equal(this.timers.size, 1);
|
||||||
|
const [handle, timer] = [...this.timers.entries()][0]!;
|
||||||
|
this.timers.delete(handle);
|
||||||
|
timer.fn();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class RecordingWireClient implements PlatformWireClient {
|
||||||
|
readonly sent: OutboundEnvelope[] = [];
|
||||||
|
readonly switchCalls: string[] = [];
|
||||||
|
readonly lifecycle: string[] = [];
|
||||||
|
reconnectCalls = 0;
|
||||||
|
stopCalls = 0;
|
||||||
|
onAction: (name: string) => void = () => {};
|
||||||
|
private readonly listeners = new Set<(event: WireEvent) => void>();
|
||||||
|
|
||||||
|
subscribe(listener: (event: WireEvent) => void): () => void {
|
||||||
|
this.lifecycle.push('subscribe');
|
||||||
|
this.onAction('subscribe');
|
||||||
|
this.listeners.add(listener);
|
||||||
|
return () => {
|
||||||
|
this.lifecycle.push('unsubscribe');
|
||||||
|
this.onAction('unsubscribe');
|
||||||
|
this.listeners.delete(listener);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
start(): void {
|
||||||
|
this.lifecycle.push('start');
|
||||||
|
this.onAction('start');
|
||||||
|
}
|
||||||
|
|
||||||
|
send(envelope: OutboundEnvelope): void {
|
||||||
|
this.sent.push(envelope);
|
||||||
|
}
|
||||||
|
|
||||||
|
switchServer(target: string): void {
|
||||||
|
this.switchCalls.push(target);
|
||||||
|
}
|
||||||
|
|
||||||
|
reconnectCurrent(): void {
|
||||||
|
this.reconnectCalls += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
stop(): void {
|
||||||
|
this.stopCalls += 1;
|
||||||
|
this.lifecycle.push('stop');
|
||||||
|
this.onAction('stop');
|
||||||
|
}
|
||||||
|
|
||||||
|
emit(event: WireEvent): void {
|
||||||
|
for (const listener of [...this.listeners]) listener(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class RecordingScene implements ScenePort {
|
||||||
|
readonly calls: Array<{ readonly name: string; readonly value?: unknown }> = [];
|
||||||
|
onCall: (name: string, value?: unknown) => void = () => {};
|
||||||
|
private record(name: string, value?: unknown): void {
|
||||||
|
this.calls.push(value === undefined ? { name } : { name, value });
|
||||||
|
this.onCall(name, value);
|
||||||
|
}
|
||||||
|
showLoading(): void { this.record('loading'); }
|
||||||
|
showLogin(): void { this.record('login'); }
|
||||||
|
showLobby(): void { this.record('lobby'); }
|
||||||
|
showRoom(): void { this.record('room'); }
|
||||||
|
showReconnect(): void { this.record('reconnect'); }
|
||||||
|
showKicked(data: unknown): void { this.record('kicked', data); }
|
||||||
|
showFatal(error: Error): void { this.record('fatal', error); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeGameEntry(overrides: Partial<GameEntry> = {}): GameEntry {
|
||||||
|
return {
|
||||||
|
key: 'fixture',
|
||||||
|
gameId: '41',
|
||||||
|
route: 'fixture-game',
|
||||||
|
resolveSeatCount: () => 4,
|
||||||
|
createModule: (): GameModule => ({
|
||||||
|
attach() {},
|
||||||
|
handlePlatformEvent() {},
|
||||||
|
handleGameMessage() {},
|
||||||
|
restore() {},
|
||||||
|
dispose() {},
|
||||||
|
}),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeRuntime(config: RuntimeConfig = runtimeConfig(), entry = makeGameEntry()) {
|
||||||
|
const wire = new RecordingWireClient();
|
||||||
|
const scene = new RecordingScene();
|
||||||
|
const clock = new ManualClock();
|
||||||
|
const factoryConfigs: RuntimeConfig[] = [];
|
||||||
|
const runtime = new PlatformRuntime({
|
||||||
|
gameEntry: entry,
|
||||||
|
resolveRuntimeConfig: async () => config,
|
||||||
|
createWireClient(resolved) {
|
||||||
|
factoryConfigs.push(resolved);
|
||||||
|
return wire;
|
||||||
|
},
|
||||||
|
scene,
|
||||||
|
loadResources: async () => {},
|
||||||
|
waitForMinimumDisplay: async () => {},
|
||||||
|
getLoginDeviceSnapshot: () => DEVICE,
|
||||||
|
clock,
|
||||||
|
});
|
||||||
|
return { runtime, wire, scene, clock, factoryConfigs, config };
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeLoggingEntry(log: string[], restored: unknown[], attached: GameHost[]): GameEntry {
|
||||||
|
let recording = false;
|
||||||
|
const entry = makeGameEntry({
|
||||||
|
createModule: (): GameModule => {
|
||||||
|
let host: GameHost | null = null;
|
||||||
|
return {
|
||||||
|
attach(value) {
|
||||||
|
host = value;
|
||||||
|
if (!recording) return;
|
||||||
|
attached.push(value);
|
||||||
|
log.push(`attach:${value.getSnapshot().room.roomcode}`);
|
||||||
|
},
|
||||||
|
handlePlatformEvent(event: PlatformToGameEvent) {
|
||||||
|
if (recording) log.push(`event:${event.type}`);
|
||||||
|
},
|
||||||
|
handleGameMessage() {},
|
||||||
|
restore(deskinfo) {
|
||||||
|
if (!recording) return;
|
||||||
|
assert.ok(host);
|
||||||
|
restored.push(deskinfo);
|
||||||
|
log.push('restore');
|
||||||
|
},
|
||||||
|
dispose() {
|
||||||
|
if (!recording) return;
|
||||||
|
assert.ok(host);
|
||||||
|
host.getSnapshot();
|
||||||
|
log.push('dispose');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
Object.defineProperty(entry, 'beginRecording', {
|
||||||
|
value: () => { recording = true; },
|
||||||
|
});
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
function beginEntryRecording(entry: GameEntry): void {
|
||||||
|
(entry as GameEntry & { beginRecording(): void }).beginRecording();
|
||||||
|
}
|
||||||
|
|
||||||
|
test('start resolves config, subscribes before connect, and never logs in without an account', async () => {
|
||||||
|
const setup = makeRuntime();
|
||||||
|
|
||||||
|
await setup.runtime.start();
|
||||||
|
setup.wire.emit({ type: 'open', server: 'ws://agent' });
|
||||||
|
|
||||||
|
assert.equal(setup.factoryConfigs.length, 1);
|
||||||
|
assert.equal(setup.factoryConfigs[0], setup.config);
|
||||||
|
assert.deepEqual(setup.wire.lifecycle, ['subscribe', 'start']);
|
||||||
|
assert.deepEqual(setup.wire.sent, []);
|
||||||
|
assert.equal(setup.runtime.ready, true);
|
||||||
|
assert.deepEqual(setup.scene.calls.map((call) => call.name), ['loading', 'login']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('login sends one exact request and an ordinary reopen resends the same data with a four-second guard', async () => {
|
||||||
|
const setup = makeRuntime();
|
||||||
|
await setup.runtime.start();
|
||||||
|
setup.wire.emit({ type: 'open', server: 'ws://agent' });
|
||||||
|
|
||||||
|
setup.runtime.login(ACCOUNT);
|
||||||
|
|
||||||
|
assert.deepEqual(setup.wire.sent[0], {
|
||||||
|
app: 'youle',
|
||||||
|
route: 'agent',
|
||||||
|
rpc: 'player_login',
|
||||||
|
data: {
|
||||||
|
agentid: 'veRa0qrBf', gameid: '41', openid: 'o1', nickname: '测试号',
|
||||||
|
avatar: 'http://a', sex: 0, province: '江西', city: '南昌',
|
||||||
|
unionid: 'test_unionid', version: 10000, channelid: 'FtJf073aa',
|
||||||
|
marketid: 4, ip: '127.0.0.1', location: DEVICE.location,
|
||||||
|
machineid: 'machine-1', machineroom: 'room-install-1',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.deepEqual([...setup.clock.timers.values()].map((timer) => timer.ms), [4000]);
|
||||||
|
|
||||||
|
setup.wire.emit({
|
||||||
|
type: 'message',
|
||||||
|
message: { route: 'agent', rpc: 'player_login', data: fixture('player-login-success.json') },
|
||||||
|
});
|
||||||
|
assert.equal(setup.clock.timers.size, 0);
|
||||||
|
|
||||||
|
setup.wire.emit({ type: 'open', server: 'ws://agent' });
|
||||||
|
|
||||||
|
assert.equal(setup.wire.sent.length, 2);
|
||||||
|
assert.equal(setup.wire.sent[1]?.rpc, 'player_login');
|
||||||
|
assert.equal(setup.wire.sent[1]?.data, setup.wire.sent[0]?.data);
|
||||||
|
assert.deepEqual([...setup.clock.timers.values()].map((timer) => timer.ms), [4000]);
|
||||||
|
setup.clock.runOnlyTimer();
|
||||||
|
assert.equal(setup.wire.reconnectCalls, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const mismatch of [
|
||||||
|
{ label: 'different value', configGameId: '42', entryGameId: '41' },
|
||||||
|
{ label: 'different type', configGameId: '41', entryGameId: 41 },
|
||||||
|
] as const) {
|
||||||
|
test(`gameid ${mismatch.label} fails fatally before WireClient creation`, async () => {
|
||||||
|
const setup = makeRuntime(
|
||||||
|
runtimeConfig(mismatch.configGameId),
|
||||||
|
makeGameEntry({ gameId: mismatch.entryGameId }),
|
||||||
|
);
|
||||||
|
|
||||||
|
await assert.rejects(() => setup.runtime.start(), /gameid/i);
|
||||||
|
await assert.rejects(() => setup.runtime.start(), /fatal/i);
|
||||||
|
|
||||||
|
assert.equal(setup.factoryConfigs.length, 0);
|
||||||
|
assert.deepEqual(setup.wire.lifecycle, []);
|
||||||
|
const fatalCalls = setup.scene.calls.filter((call) => call.name === 'fatal');
|
||||||
|
assert.equal(fatalCalls.length, 1);
|
||||||
|
assert.ok(fatalCalls[0]?.value instanceof Error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const switchCase of [
|
||||||
|
{
|
||||||
|
label: 'room',
|
||||||
|
route: 'room',
|
||||||
|
rpc: 'connect_roomserver',
|
||||||
|
target: 'ws://room.example:3088',
|
||||||
|
data: { roomserver: 'ws://room.example:3088', epoch: 7, nested: { trace: 'r' } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'agent',
|
||||||
|
route: 'agent',
|
||||||
|
rpc: 'connect_agentserver',
|
||||||
|
target: 'wss://agent.example/socket',
|
||||||
|
data: {
|
||||||
|
agentserver: 'wss://agent.example/socket',
|
||||||
|
opt: 'free_room',
|
||||||
|
nested: { trace: 'a' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
] as const) {
|
||||||
|
test(`${switchCase.label} switch resends the exact switch envelope before returning to steady login`, async () => {
|
||||||
|
const setup = makeRuntime();
|
||||||
|
await setup.runtime.start();
|
||||||
|
setup.wire.emit({ type: 'open', server: 'ws://agent' });
|
||||||
|
setup.runtime.login(ACCOUNT);
|
||||||
|
const loginEnvelope = setup.wire.sent[0]!;
|
||||||
|
setup.wire.emit({
|
||||||
|
type: 'message',
|
||||||
|
message: { route: 'agent', rpc: 'player_login', data: fixture('player-login-success.json') },
|
||||||
|
});
|
||||||
|
setup.wire.sent.length = 0;
|
||||||
|
|
||||||
|
setup.wire.emit({
|
||||||
|
type: 'message',
|
||||||
|
message: { route: switchCase.route, rpc: switchCase.rpc, data: switchCase.data },
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(setup.wire.switchCalls, [switchCase.target]);
|
||||||
|
assert.equal(setup.wire.sent.length, 0);
|
||||||
|
|
||||||
|
setup.wire.emit({ type: 'open', server: switchCase.target });
|
||||||
|
|
||||||
|
assert.equal(setup.wire.sent.length, 1);
|
||||||
|
assert.equal(setup.wire.sent[0]?.rpc, switchCase.rpc);
|
||||||
|
assert.deepEqual(setup.wire.sent[0], {
|
||||||
|
app: 'youle',
|
||||||
|
route: switchCase.route,
|
||||||
|
rpc: switchCase.rpc,
|
||||||
|
data: switchCase.data,
|
||||||
|
});
|
||||||
|
assert.equal(setup.wire.sent[0]?.data, switchCase.data);
|
||||||
|
assert.equal(setup.clock.timers.size, 0, 'switch resend must not start the login guard');
|
||||||
|
|
||||||
|
setup.wire.emit({ type: 'open', server: switchCase.target });
|
||||||
|
|
||||||
|
assert.equal(setup.wire.sent.length, 2);
|
||||||
|
assert.equal(setup.wire.sent[1]?.rpc, 'player_login');
|
||||||
|
assert.equal(setup.wire.sent[1]?.data, loginEnvelope.data);
|
||||||
|
assert.deepEqual([...setup.clock.timers.values()].map((timer) => timer.ms), [4000]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('successful login without a room shows the lobby', async () => {
|
||||||
|
const setup = makeRuntime();
|
||||||
|
await setup.runtime.start();
|
||||||
|
setup.wire.emit({ type: 'open', server: 'ws://agent' });
|
||||||
|
setup.runtime.login(ACCOUNT);
|
||||||
|
|
||||||
|
setup.wire.emit({
|
||||||
|
type: 'message',
|
||||||
|
message: { route: 'agent', rpc: 'player_login', data: fixture('player-login-success.json') },
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(setup.runtime.state.room.kind, 'outside');
|
||||||
|
assert.equal(setup.scene.calls[setup.scene.calls.length - 1]?.name, 'lobby');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('room login commits and opens before showRoom, then restores the exact deskinfo', async () => {
|
||||||
|
const log: string[] = [];
|
||||||
|
const restored: unknown[] = [];
|
||||||
|
const attached: GameHost[] = [];
|
||||||
|
const entry = makeLoggingEntry(log, restored, attached);
|
||||||
|
const setup = makeRuntime(runtimeConfig(), entry);
|
||||||
|
setup.scene.onCall = (name) => { if (name === 'room') log.push('scene:room'); };
|
||||||
|
await setup.runtime.start();
|
||||||
|
beginEntryRecording(entry);
|
||||||
|
setup.wire.emit({ type: 'open', server: 'ws://agent' });
|
||||||
|
setup.runtime.login(ACCOUNT);
|
||||||
|
const roomLogin = fixture('player-login-room.json');
|
||||||
|
|
||||||
|
setup.wire.emit({
|
||||||
|
type: 'message',
|
||||||
|
message: { route: 'agent', rpc: 'player_login', data: roomLogin },
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(setup.runtime.state.room.kind, 'inside');
|
||||||
|
assert.deepEqual(log, [
|
||||||
|
'attach:100001',
|
||||||
|
'event:room.entered',
|
||||||
|
'scene:room',
|
||||||
|
'restore',
|
||||||
|
]);
|
||||||
|
assert.deepEqual(restored, [roomLogin.deskinfo]);
|
||||||
|
assert.equal(restored[0], roomLogin.deskinfo);
|
||||||
|
assert.equal(attached.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const withDeskinfo of [false, true]) {
|
||||||
|
test(`self join opens and shows the room ${withDeskinfo ? 'before restoring deskinfo' : 'without restoring absent deskinfo'}`, async () => {
|
||||||
|
const log: string[] = [];
|
||||||
|
const restored: unknown[] = [];
|
||||||
|
const attached: GameHost[] = [];
|
||||||
|
const entry = makeLoggingEntry(log, restored, attached);
|
||||||
|
const setup = makeRuntime(runtimeConfig(), entry);
|
||||||
|
setup.scene.onCall = (name) => { if (name === 'room') log.push('scene:room'); };
|
||||||
|
await setup.runtime.start();
|
||||||
|
beginEntryRecording(entry);
|
||||||
|
setup.wire.emit({ type: 'open', server: 'ws://agent' });
|
||||||
|
setup.runtime.login(ACCOUNT);
|
||||||
|
setup.wire.emit({
|
||||||
|
type: 'message',
|
||||||
|
message: { route: 'agent', rpc: 'player_login', data: fixture('player-login-success.json') },
|
||||||
|
});
|
||||||
|
log.length = 0;
|
||||||
|
const join = fixture('self-join-room.json');
|
||||||
|
const deskinfo = join.deskinfo;
|
||||||
|
if (!withDeskinfo) delete join.deskinfo;
|
||||||
|
|
||||||
|
setup.wire.emit({
|
||||||
|
type: 'message',
|
||||||
|
message: { route: 'agent', rpc: 'self_join_room', data: join },
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(log, withDeskinfo
|
||||||
|
? ['attach:100001', 'event:room.entered', 'scene:room', 'restore']
|
||||||
|
: ['attach:100001', 'event:room.entered', 'scene:room']);
|
||||||
|
assert.deepEqual(restored, withDeskinfo ? [deskinfo] : []);
|
||||||
|
if (withDeskinfo) assert.equal(restored[0], deskinfo);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('slow and reconnecting show reconnect without clearing or replacing room state', async () => {
|
||||||
|
const setup = makeRuntime();
|
||||||
|
await setup.runtime.start();
|
||||||
|
setup.wire.emit({ type: 'open', server: 'ws://agent' });
|
||||||
|
setup.runtime.login(ACCOUNT);
|
||||||
|
setup.wire.emit({
|
||||||
|
type: 'message',
|
||||||
|
message: { route: 'agent', rpc: 'player_login', data: fixture('player-login-room.json') },
|
||||||
|
});
|
||||||
|
const roomState = setup.runtime.state;
|
||||||
|
const callsBefore = setup.scene.calls.length;
|
||||||
|
|
||||||
|
setup.wire.emit({ type: 'slow' });
|
||||||
|
setup.wire.emit({ type: 'reconnecting', server: 'ws://agent' });
|
||||||
|
|
||||||
|
assert.equal(setup.runtime.state, roomState);
|
||||||
|
assert.equal(setup.runtime.state.room.kind, 'inside');
|
||||||
|
assert.deepEqual(
|
||||||
|
setup.scene.calls.slice(callsBefore).map((call) => call.name),
|
||||||
|
['reconnect', 'reconnect'],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('kick closes the game before showing kicked and stopping the wire', async () => {
|
||||||
|
const log: string[] = [];
|
||||||
|
const restored: unknown[] = [];
|
||||||
|
const attached: GameHost[] = [];
|
||||||
|
const entry = makeLoggingEntry(log, restored, attached);
|
||||||
|
const setup = makeRuntime(runtimeConfig(), entry);
|
||||||
|
setup.scene.onCall = (name) => { if (name === 'kicked') log.push('scene:kicked'); };
|
||||||
|
setup.wire.onAction = (name) => { if (name === 'stop') log.push('wire:stop'); };
|
||||||
|
await setup.runtime.start();
|
||||||
|
beginEntryRecording(entry);
|
||||||
|
setup.wire.emit({ type: 'open', server: 'ws://agent' });
|
||||||
|
setup.runtime.login(ACCOUNT);
|
||||||
|
setup.wire.emit({
|
||||||
|
type: 'message',
|
||||||
|
message: { route: 'agent', rpc: 'player_login', data: fixture('player-login-room.json') },
|
||||||
|
});
|
||||||
|
log.length = 0;
|
||||||
|
const kick = { msg: '检查到新版本', code: 1 };
|
||||||
|
|
||||||
|
setup.wire.emit({
|
||||||
|
type: 'message',
|
||||||
|
message: { route: 'agent', rpc: 'kick_server', data: kick },
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(log, ['dispose', 'scene:kicked', 'wire:stop']);
|
||||||
|
assert.equal(setup.wire.stopCalls, 1);
|
||||||
|
assert.equal(setup.scene.calls[setup.scene.calls.length - 1]?.value, kick);
|
||||||
|
|
||||||
|
const sceneCalls = setup.scene.calls.length;
|
||||||
|
setup.wire.emit({ type: 'reconnecting', server: 'ws://agent' });
|
||||||
|
setup.wire.emit({ type: 'open', server: 'ws://agent' });
|
||||||
|
assert.equal(setup.scene.calls.length, sceneCalls, 'late wire events stay silent after kick');
|
||||||
|
assert.equal(setup.wire.sent.length, 1, 'late open never revives login after kick');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a game rpc named like a platform rpc cannot drive platform scenes', async () => {
|
||||||
|
const setup = makeRuntime();
|
||||||
|
await setup.runtime.start();
|
||||||
|
setup.wire.emit({ type: 'open', server: 'ws://agent' });
|
||||||
|
setup.runtime.login(ACCOUNT);
|
||||||
|
setup.wire.emit({
|
||||||
|
type: 'message',
|
||||||
|
message: { route: 'agent', rpc: 'player_login', data: fixture('player-login-room.json') },
|
||||||
|
});
|
||||||
|
const sceneCalls = setup.scene.calls.length;
|
||||||
|
|
||||||
|
setup.wire.emit({
|
||||||
|
type: 'message',
|
||||||
|
message: { route: 'fixture-game', rpc: 'self_exit_room', data: { reason: 'game-owned' } },
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(setup.runtime.state.room.kind, 'inside');
|
||||||
|
assert.equal(setup.scene.calls.length, sceneCalls);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('joinRoom, prepare, and exitRoom delegate through the authoritative platform commands', async () => {
|
||||||
|
const setup = makeRuntime();
|
||||||
|
await setup.runtime.start();
|
||||||
|
setup.wire.emit({ type: 'open', server: 'ws://agent' });
|
||||||
|
setup.runtime.login(ACCOUNT);
|
||||||
|
setup.wire.emit({
|
||||||
|
type: 'message',
|
||||||
|
message: { route: 'agent', rpc: 'player_login', data: fixture('player-login-success.json') },
|
||||||
|
});
|
||||||
|
setup.wire.sent.length = 0;
|
||||||
|
const location = { latitude: 28.7, longitude: 115.8 };
|
||||||
|
|
||||||
|
setup.runtime.joinRoom({
|
||||||
|
roomcode: '100001',
|
||||||
|
location,
|
||||||
|
ip: '203.0.113.7',
|
||||||
|
vipMatch: 1,
|
||||||
|
match_id: 'match-9',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(setup.wire.sent, [{
|
||||||
|
app: 'youle',
|
||||||
|
route: 'agent',
|
||||||
|
rpc: 'self_join_room',
|
||||||
|
data: {
|
||||||
|
agentid: 'veRa0qrBf',
|
||||||
|
playerid: 430511,
|
||||||
|
gameid: '41',
|
||||||
|
roomcode: '100001',
|
||||||
|
location,
|
||||||
|
ip: '203.0.113.7',
|
||||||
|
vipMatch: 1,
|
||||||
|
match_id: 'match-9',
|
||||||
|
},
|
||||||
|
}]);
|
||||||
|
assert.equal((setup.wire.sent[0]!.data as { location: unknown }).location, location);
|
||||||
|
|
||||||
|
const joinedRoom = fixture('self-join-room.json');
|
||||||
|
delete joinedRoom.deskinfo;
|
||||||
|
joinedRoom.deskwar = 0;
|
||||||
|
setup.wire.emit({
|
||||||
|
type: 'message',
|
||||||
|
message: { route: 'agent', rpc: 'self_join_room', data: joinedRoom },
|
||||||
|
});
|
||||||
|
setup.wire.sent.length = 0;
|
||||||
|
setup.runtime.prepare();
|
||||||
|
setup.runtime.exitRoom();
|
||||||
|
|
||||||
|
assert.deepEqual(setup.wire.sent, [
|
||||||
|
{
|
||||||
|
app: 'youle',
|
||||||
|
route: 'room',
|
||||||
|
rpc: 'player_prepare',
|
||||||
|
data: {
|
||||||
|
agentid: 'veRa0qrBf', playerid: 430511, gameid: '41', roomcode: '100001',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
app: 'youle',
|
||||||
|
route: 'room',
|
||||||
|
rpc: 'self_exit_room',
|
||||||
|
data: {
|
||||||
|
agentid: 'veRa0qrBf', playerid: 430511, gameid: '41', roomcode: '100001',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('stop unsubscribes, disposes while the host is live, invalidates it, then stops the wire', async () => {
|
||||||
|
const log: string[] = [];
|
||||||
|
const restored: unknown[] = [];
|
||||||
|
const attached: GameHost[] = [];
|
||||||
|
const entry = makeLoggingEntry(log, restored, attached);
|
||||||
|
const setup = makeRuntime(runtimeConfig(), entry);
|
||||||
|
setup.wire.onAction = (name) => {
|
||||||
|
if (name === 'unsubscribe' || name === 'stop') log.push(`wire:${name}`);
|
||||||
|
};
|
||||||
|
await setup.runtime.start();
|
||||||
|
beginEntryRecording(entry);
|
||||||
|
setup.wire.emit({ type: 'open', server: 'ws://agent' });
|
||||||
|
setup.runtime.login(ACCOUNT);
|
||||||
|
setup.wire.emit({
|
||||||
|
type: 'message',
|
||||||
|
message: { route: 'agent', rpc: 'player_login', data: fixture('player-login-room.json') },
|
||||||
|
});
|
||||||
|
const activeHost = attached[0]!;
|
||||||
|
log.length = 0;
|
||||||
|
|
||||||
|
setup.runtime.stop();
|
||||||
|
|
||||||
|
assert.deepEqual(log, ['wire:unsubscribe', 'dispose', 'wire:stop']);
|
||||||
|
assert.throws(() => activeHost.getSnapshot(), /disposed/i);
|
||||||
|
setup.runtime.stop();
|
||||||
|
assert.deepEqual(log, ['wire:unsubscribe', 'dispose', 'wire:stop']);
|
||||||
|
assert.equal(setup.wire.stopCalls, 1);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user