From 1a3308ecd0511f50e296a8a8e8d9447c82f10e9e Mon Sep 17 00:00:00 2001 From: Joywayer Date: Sat, 5 Sep 2026 18:19:11 +0800 Subject: [PATCH] feat: bound local platform login diagnostics and wire scope --- .../local-login-game-entry.ts | 33 ++++ .../local-platform-login/local-login-wire.ts | 120 +++++++++++++++ .../local-platform-login/game-entry.test.ts | 51 +++++++ .../local-platform-login/wire.test.ts | 144 ++++++++++++++++++ 4 files changed, 348 insertions(+) create mode 100644 cocoscreator_projects/YouleNexus/assets/scripts/local-platform-login/local-login-game-entry.ts create mode 100644 cocoscreator_projects/YouleNexus/assets/scripts/local-platform-login/local-login-wire.ts create mode 100644 cocoscreator_projects/framework-tests/local-platform-login/game-entry.test.ts create mode 100644 cocoscreator_projects/framework-tests/local-platform-login/wire.test.ts diff --git a/cocoscreator_projects/YouleNexus/assets/scripts/local-platform-login/local-login-game-entry.ts b/cocoscreator_projects/YouleNexus/assets/scripts/local-platform-login/local-login-game-entry.ts new file mode 100644 index 0000000..6e39db3 --- /dev/null +++ b/cocoscreator_projects/YouleNexus/assets/scripts/local-platform-login/local-login-game-entry.ts @@ -0,0 +1,33 @@ +import type { GameEntry } from '../../framework/sdk/contracts/game-entry.ts'; +import type { EvidenceSink } from './local-login-transport.ts'; + +/** Satisfies entry validation while making every actual game operation a scope error. */ +export function createDiagnosticGameEntry(gameId: string | number, record: EvidenceSink): GameEntry { + let nextInstanceId = 0; + function violation(operation: string, instanceId?: number): never { + record({ kind: 'scope-violation', data: { operation, instanceId } }); + throw new Error(`Local platform login scope forbids ${operation}`); + } + return { + key: 'local-platform-login', + route: 'local-platform-login', + gameId, + resolveSeatCount: () => violation('resolveSeatCount'), + createModule() { + const instanceId = ++nextInstanceId; + let disposed = false; + record({ kind: 'diagnostic-module', data: { instanceId, state: 'created' } }); + return { + attach: () => violation('attach', instanceId), + restore: () => violation('restore', instanceId), + handlePlatformEvent: () => violation('handlePlatformEvent', instanceId), + handleGameMessage: () => violation('handleGameMessage', instanceId), + dispose() { + if (disposed) return; + disposed = true; + record({ kind: 'diagnostic-module', data: { instanceId, state: 'disposed' } }); + }, + }; + }, + }; +} diff --git a/cocoscreator_projects/YouleNexus/assets/scripts/local-platform-login/local-login-wire.ts b/cocoscreator_projects/YouleNexus/assets/scripts/local-platform-login/local-login-wire.ts new file mode 100644 index 0000000..d4e1f67 --- /dev/null +++ b/cocoscreator_projects/YouleNexus/assets/scripts/local-platform-login/local-login-wire.ts @@ -0,0 +1,120 @@ +import type { LocalStartupContext } from '../../framework/config/local-startup.ts'; +import type { Transport } from '../../framework/net/transport.ts'; +import { WireClient, type WireEvent } from '../../framework/net/wire-client.ts'; +import type { PlatformWireClient } from '../../framework/platform/runtime.ts'; +import { parseLoginResponse } from '../../framework/protocol/contracts/index.ts'; +import type { EvidenceSink } from './local-login-transport.ts'; + +interface LocalLoginWireOptions { + context: LocalStartupContext; + transportFactory: () => Transport; + onFault: (error: unknown) => void; + record: EvidenceSink; +} + +/** ES2020-compatible aggregate retaining original errors, including non-Error values. */ +class LocalLoginWireFault extends Error { + readonly errors: readonly unknown[]; + constructor(errors: readonly unknown[]) { + super('Local login Wire failed with additional cleanup or evidence errors'); + this.name = 'LocalLoginWireFault'; + this.errors = Object.freeze([...errors]); + } +} + +export function createLocalLoginWire(options: LocalLoginWireOptions): PlatformWireClient { + const wire = new WireClient({ servers: options.context.config.servers, transportFactory: options.transportFactory }); + const listeners = new Set<(event: WireEvent) => void>(); + let stopped = false; + let faulted = false; + let terminalDelivery = false; + + function stop(): void { + if (stopped) return; + stopped = true; + unsubscribe(); + listeners.clear(); + wire.stop(); + } + + function fail(error: unknown, operation?: string, event?: WireEvent): unknown { + if (faulted) return error; + faulted = true; + const errors = [error]; + try { stop(); } catch (cleanupError) { errors.push(cleanupError); } + if (operation !== undefined) { + try { options.record({ kind: 'scope-violation', data: { operation, event } }); } + catch (recordError) { errors.push(recordError); } + } + const fault = errors.length === 1 ? error : new LocalLoginWireFault(errors); + options.onFault(fault); + return fault; + } + + function scopeError(operation: string): Error { + return new Error(`Local platform login scope forbids ${operation}`); + } + + function assertActive(): void { + if (stopped || faulted || terminalDelivery) throw new Error('Local login Wire is stopped or terminal'); + } + + function deliver(event: WireEvent): void { + for (const listener of [...listeners]) { + if (stopped || faulted) break; + listener(event); + } + } + + const unsubscribe = wire.subscribe(event => { + if (stopped || faulted || terminalDelivery) return; + try { + options.record({ kind: 'wire-event', data: event }); + if (stopped || faulted) return; + if (event.type !== 'message') { deliver(event); return; } + const { route, rpc, data } = event.message; + if (rpc === 'connect_agentserver' || rpc === 'connect_roomserver' || (route !== 'agent' && route !== 'platform')) { + fail(scopeError(`${route}/${rpc}`), `${route}/${rpc}`, event); + return; + } + let terminalOperation: string | undefined; + if (route === 'agent' && rpc === 'player_login') { + const parsed = parseLoginResponse(data); + if (parsed.state === 0 && parsed.room !== null) { + fail(scopeError('login room recovery'), 'login room recovery', event); + return; + } + if (parsed.state !== 0) terminalOperation = 'login rejected'; + } + if (route === 'agent' && rpc === 'kick_server') terminalOperation = 'kick_server'; + if (terminalOperation !== undefined) { + // Existing runtime failure display runs first, but cannot initiate more traffic. + terminalDelivery = true; + try { deliver(event); } + catch (error) { fail(error); return; } + fail(scopeError(terminalOperation), terminalOperation, event); + return; + } + deliver(event); + } catch (error) { fail(error); } + }); + + return { + subscribe(listener) { + if (stopped || faulted) return () => {}; + listeners.add(listener); + return () => { listeners.delete(listener); }; + }, + start() { assertActive(); wire.start(); }, + send(envelope) { + assertActive(); + if (envelope.route !== 'agent' || envelope.rpc !== 'player_login') { + throw fail(scopeError(`outbound ${envelope.route}/${envelope.rpc}`), `outbound ${envelope.route}/${envelope.rpc}`); + } + wire.send(envelope); + }, + switchServer() { assertActive(); throw fail(scopeError('switchServer'), 'switchServer'); }, + reconnectCurrent() { assertActive(); wire.reconnectCurrent(); }, + stop, + }; +} diff --git a/cocoscreator_projects/framework-tests/local-platform-login/game-entry.test.ts b/cocoscreator_projects/framework-tests/local-platform-login/game-entry.test.ts new file mode 100644 index 0000000..68aa08b --- /dev/null +++ b/cocoscreator_projects/framework-tests/local-platform-login/game-entry.test.ts @@ -0,0 +1,51 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { GameSessionHost } from '../../YouleNexus/assets/framework/sdk/runtime/game-session-host.ts'; +import type { GameHost, PlatformToGameEvent, GameServerMessage } from '../../YouleNexus/assets/framework/sdk/contracts/index.ts'; + +async function setup() { + const api = await import('../../YouleNexus/assets/scripts/local-platform-login/local-login-game-entry.ts') + .catch(error => assert.fail(`Diagnostic GameEntry implementation must exist: ${String(error)}`)); + const evidence: { kind: string; data: unknown }[] = []; + return { entry: api.createDiagnosticGameEntry(101, event => evidence.push(event)), evidence }; +} + +test('real GameSessionHost validates and releases one diagnostic instance without entering a game', async () => { + const { entry, evidence } = await setup(); + const host = new GameSessionHost(entry); + assert.equal(host.state, 'idle'); + assert.equal(entry.key, 'local-platform-login'); + assert.equal(entry.route, 'local-platform-login'); + assert.equal(entry.gameId, 101); + assert.deepEqual(evidence, [ + { kind: 'diagnostic-module', data: { instanceId: 1, state: 'created' } }, + { kind: 'diagnostic-module', data: { instanceId: 1, state: 'disposed' } }, + ]); + host.dispose(); + host.dispose(); + assert.equal(evidence.length, 2); +}); + +test('every game behavior records its operation and throws; dispose is idempotent', async () => { + const { entry, evidence } = await setup(); + assert.throws(() => entry.resolveSeatCount([]), /resolveSeatCount/); + const module = entry.createModule(); + for (const [operation, invoke] of [ + ['attach', () => module.attach({} as GameHost)], + ['restore', () => module.restore({})], + ['handlePlatformEvent', () => module.handlePlatformEvent({} as PlatformToGameEvent)], + ['handleGameMessage', () => module.handleGameMessage({} as GameServerMessage)], + ] as const) { + assert.throws(invoke, new RegExp(operation)); + assert.equal(evidence[evidence.length - 1]?.kind, 'scope-violation'); + assert.equal((evidence[evidence.length - 1]?.data as { operation: string }).operation, operation); + } + module.dispose(); + module.dispose(); + entry.createModule().dispose(); + const lifecycle = evidence.filter(event => event.kind === 'diagnostic-module'); + assert.deepEqual(lifecycle.map(event => event.data), [ + { instanceId: 1, state: 'created' }, { instanceId: 1, state: 'disposed' }, + { instanceId: 2, state: 'created' }, { instanceId: 2, state: 'disposed' }, + ]); +}); diff --git a/cocoscreator_projects/framework-tests/local-platform-login/wire.test.ts b/cocoscreator_projects/framework-tests/local-platform-login/wire.test.ts new file mode 100644 index 0000000..080e640 --- /dev/null +++ b/cocoscreator_projects/framework-tests/local-platform-login/wire.test.ts @@ -0,0 +1,144 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { test } from 'node:test'; +import { resolveLocalStartup } from '../../YouleNexus/assets/framework/config/local-startup.ts'; +import { RECONNECT_INTERVAL_MS } from '../../YouleNexus/assets/framework/core/constants.ts'; +import type { WireEvent } from '../../YouleNexus/assets/framework/net/wire-client.ts'; +import type { OutboundEnvelope } from '../../YouleNexus/assets/framework/core/types/envelope.ts'; +import { FakeTransport } from '../helpers/fake-transport.ts'; + +const login: OutboundEnvelope = { app: 'youle', route: 'agent', rpc: 'player_login', data: { gameid: 101 } }; +function fixture(name: string) { + return JSON.parse(readFileSync(new URL(`../fixtures/contracts/${name}`, import.meta.url), 'utf8')); +} +async function setup(recordHook?: (event: { kind: string; data: unknown }) => void) { + const api = await import('../../YouleNexus/assets/scripts/local-platform-login/local-login-wire.ts') + .catch(error => assert.fail(`Local login Wire implementation must exist: ${String(error)}`)); + const context = await resolveLocalStartup({ mode: 'debug', hostKind: 'h5', win: {}, search: '?profile=local', + fetcher: { async fetch(): Promise { throw new Error('unexpected fetch'); } } }); + const transports: FakeTransport[] = []; + const events: WireEvent[] = []; + const evidence: { kind: string; data: unknown }[] = []; + const faults: unknown[] = []; + const order: string[] = []; + const wire = api.createLocalLoginWire({ context, + transportFactory: () => { const transport = new FakeTransport(); transports.push(transport); return transport; }, + record: event => { evidence.push(event); recordHook?.(event); }, + onFault: error => { order.push('fault'); faults.push(error); assert.equal(transports[0]?.closeCalls, 1); }, + }); + wire.subscribe(event => { events.push(event); order.push(event.type === 'message' ? event.message.rpc : event.type); }); + const push = async (message: unknown) => { transports[0]!.serverPush(JSON.stringify(message)); await transports[0]!.flush(); }; + return { wire, context, transports, events, evidence, faults, order, push }; +} + +test('start is not open; only original lobby and ordinary agent/platform events are forwarded', async t => { + const h = await setup(); t.after(() => h.wire.stop()); + h.wire.start(); + assert.equal(h.events.length, 0); + await h.transports[0]!.flush(); + let unsubscribed = 0; + const unsubscribe = h.wire.subscribe(() => unsubscribed++); unsubscribe(); unsubscribe(); + await h.push(fixture('player-login-success.json')); + await h.push({ route: 'agent', rpc: 'show_message', data: { msg: 'hello' } }); + await h.push({ route: 'platform', rpc: 'notice', data: {} }); + assert.equal(unsubscribed, 0); + assert.equal(h.events.filter(event => event.type === 'message').length, 3); + for (const event of h.events) assert.ok(h.evidence.some(item => item.kind === 'wire-event' && item.data === event)); + assert.deepEqual(h.faults, []); +}); + +for (const [name, message, error] of [ + ['room recovery', fixture('player-login-room.json'), /login room recovery/], + ['malformed login', { route: 'agent', rpc: 'player_login', data: { state: 0 } }, /\$\.data\.playerid/], + ['room route', { route: 'room', rpc: 'notice', data: {} }, /room/], + ['game route', { route: 'mahjong', rpc: 'notice', data: {} }, /mahjong/], + ['agent switch', { route: 'agent', rpc: 'connect_agentserver', data: {} }, /connect_agentserver/], + ['platform switch', { route: 'platform', rpc: 'connect_roomserver', data: {} }, /connect_roomserver/], +] as const) test(`${name} stops before downstream delivery and remains terminal`, async t => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const h = await setup(); t.after(() => h.wire.stop()); h.wire.start(); await h.transports[0]!.flush(); + await h.push(message); + assert.equal(h.faults.length, 1); assert.match(String(h.faults[0]), error); + assert.equal(h.events.length, 1); + await h.push(fixture('player-login-success.json')); + assert.throws(() => h.wire.reconnectCurrent(), /stopped|terminal/); + assert.throws(() => h.wire.start(), /stopped|terminal/); + h.wire.stop(); h.wire.stop(); t.mock.timers.tick(60000); + assert.equal(h.transports.length, 1); assert.equal(h.events.length, 1); assert.equal(h.faults.length, 1); +}); + +for (const message of [ + { route: 'agent', rpc: 'player_login', data: { state: 1 } }, + { route: 'agent', rpc: 'kick_server', data: { msg: 'bye' } }, +]) test(`${message.rpc} failure reaches existing handlers before host notification`, async t => { + const h = await setup(); t.after(() => h.wire.stop()); h.wire.start(); await h.transports[0]!.flush(); + h.wire.subscribe(event => { if (event.type === 'message') h.wire.stop(); }); + await h.push(message); + assert.deepEqual(h.order, ['open', message.rpc, 'fault']); + assert.equal(h.faults.length, 1); +}); + +test('outbound scope and explicit switch fail without sending or reconnecting', async t => { + for (const operation of ['send', 'switch'] as const) { + const h = await setup(); t.after(() => h.wire.stop()); h.wire.start(); await h.transports[0]!.flush(); + assert.throws(() => operation === 'send' + ? h.wire.send({ ...login, rpc: 'create_room' }) : h.wire.switchServer(h.context.config.servers[0]!), /scope|switchServer/); + assert.deepEqual(h.transports[0]!.sent, []); assert.equal(h.faults.length, 1); + } +}); + +test('normal network close reconnects with the same single login envelope', async t => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const h = await setup(); t.after(() => h.wire.stop()); + h.wire.subscribe(event => { if (event.type === 'open') h.wire.send(login); }); + h.wire.start(); await h.transports[0]!.flush(); + h.transports[0]!.serverError(); await h.transports[0]!.flush(); t.mock.timers.tick(RECONNECT_INTERVAL_MS); + assert.equal(h.transports.length, 2); await h.transports[1]!.flush(); + assert.equal(h.transports[0]!.sent[0], JSON.stringify(login)); + assert.deepEqual(h.transports[1]!.sent, h.transports[0]!.sent); assert.deepEqual(h.faults, []); +}); + +test('scope fault retains its primary error and independent cleanup error', async t => { + const h = await setup(); t.after(() => h.wire.stop()); h.wire.start(); await h.transports[0]!.flush(); + const cleanup = new Error('cleanup failed'); + h.transports[0]!.close = () => { h.transports[0]!.closeCalls++; throw cleanup; }; + await h.push({ route: 'room', rpc: 'notice', data: {} }); + assert.equal(h.faults.length, 1); + const errors = (h.faults[0] as { errors: unknown[] }).errors; + assert.match(String(errors[0]), /room/); assert.equal(errors[1], cleanup); + h.wire.stop(); assert.equal(h.transports[0]!.closeCalls, 1); +}); + +test('evidence failure preserves the exact thrown value plus cleanup error and prevents delivery', async t => { + const primary = { reason: 'evidence unavailable' }; + const h = await setup(event => { + if (event.kind === 'wire-event' && (event.data as WireEvent).type === 'message') throw primary; + }); + t.after(() => h.wire.stop()); h.wire.start(); await h.transports[0]!.flush(); + const cleanup = new Error('close failed'); + h.transports[0]!.close = () => { h.transports[0]!.closeCalls++; throw cleanup; }; + await h.push(fixture('player-login-success.json')); + assert.deepEqual((h.faults[0] as { errors: unknown[] }).errors, [primary, cleanup]); + assert.equal(h.events.length, 1); assert.equal(h.faults.length, 1); +}); + +test('listener error is reported unchanged, stops remaining delivery, and stops only once', async t => { + const h = await setup(); t.after(() => h.wire.stop()); h.wire.start(); await h.transports[0]!.flush(); + const primary = new Error('downstream failed'); + h.wire.subscribe(() => { throw primary; }); + let laterDeliveries = 0; h.wire.subscribe(() => laterDeliveries++); + await h.push(fixture('player-login-success.json')); + assert.equal(h.faults[0], primary); assert.equal(laterDeliveries, 0); + h.wire.stop(); assert.equal(h.transports[0]!.closeCalls, 1); +}); + +test('terminal callback cannot send or reconnect before runtime stop; host notification is single', async t => { + const h = await setup(); t.after(() => h.wire.stop()); h.wire.start(); await h.transports[0]!.flush(); + h.wire.subscribe(event => { + if (event.type !== 'message') return; + assert.throws(() => h.wire.send(login), /terminal/); + assert.throws(() => h.wire.reconnectCurrent(), /terminal/); + }); + await h.push({ route: 'agent', rpc: 'kick_server', data: { msg: 'bye' } }); + assert.equal(h.faults.length, 1); assert.deepEqual(h.transports[0]!.sent, []); +});