diff --git a/cocoscreator_projects/YouleNexus/assets/scripts/local-platform-login/local-login-host.ts b/cocoscreator_projects/YouleNexus/assets/scripts/local-platform-login/local-login-host.ts new file mode 100644 index 0000000..00e6418 --- /dev/null +++ b/cocoscreator_projects/YouleNexus/assets/scripts/local-platform-login/local-login-host.ts @@ -0,0 +1,195 @@ +import { resolveLocalStartup } from '../../framework/config/local-startup.ts'; +import type { ResolveRuntimeConfigOptions } from '../../framework/config/runtime-config.ts'; +import { createLocalLoginSources, type LocalHostSnapshot, type LocalLoginSources } from '../../framework/adapters/local/login-sources.ts'; +import type { LocalStoragePort } from '../../framework/adapters/local/local-storage.ts'; +import { PlatformRuntime } from '../../framework/platform/runtime.ts'; +import type { PlatformState } from '../../framework/platform/stores/platform-types.ts'; +import { PlatformUiController } from '../../framework/presentation/platform-ui-controller.ts'; +import type { PlatformViewPort, FrameScheduler } from '../../framework/presentation/ui-contracts.ts'; +import { createDiagnosticGameEntry } from './local-login-game-entry.ts'; +import { createLocalLoginWire } from './local-login-wire.ts'; +import { LocalLoginTransport, type EvidenceSink } from './local-login-transport.ts'; + +export interface LocalLoginHostOptions { + configOptions: ResolveRuntimeConfigOptions; + storage: LocalStoragePort; + random: () => number; + now: () => number; + sampleHost: () => LocalHostSnapshot; + view: PlatformViewPort; + scheduler: FrameScheduler; + loadResources: () => Promise; + waitForMinimumDisplay: () => Promise; + createSocket: (url: string) => WebSocket; + record: EvidenceSink; + onBusy: (busy: boolean) => void; + onFailure: (error: unknown) => void; +} + +export interface LocalLoginHost { + start(): Promise; + login(): Promise; + stop(): void; + getSnapshot(): { state: PlatformState | null; ready: boolean; busy: boolean }; +} + +class LocalLoginHostFault extends Error { + readonly errors: readonly unknown[]; + constructor(errors: readonly unknown[]) { + super('Local login host failed with additional observer or cleanup errors'); + this.name = 'LocalLoginHostFault'; + this.errors = Object.freeze([...errors]); + } +} + +function collected(errors: readonly unknown[]): unknown { + return errors.length === 1 ? errors[0] : new LocalLoginHostFault(errors); +} + +/** Development composition only; Runtime remains the sole business state owner. */ +export function createLocalLoginHost(options: LocalLoginHostOptions): LocalLoginHost { + let runtime: PlatformRuntime | null = null; + let controller: PlatformUiController | null = null; + let sources: LocalLoginSources | null = null; + let startup: Promise | null = null; + let loginOperation: Promise | null = null; + let terminal = false; + let busy = false; + let cleanupErrors: unknown[] | null = null; + let serverFailure: { data: unknown } | null = null; + + function requireActive(): void { + if (terminal) throw new Error('Local login host is stopped or terminal'); + } + + function setBusy(value: boolean): void { + if (busy === value) return; + busy = value; + options.onBusy(value); + } + + function shutdown(errors: unknown[]): void { + terminal = true; + cleanupErrors = errors; + // Disable input before releasing either owner; dispose preserves drawn output. + try { setBusy(false); } catch (error) { errors.push(error); } + try { controller?.dispose(); } catch (error) { errors.push(error); } + try { runtime?.stop(); } catch (error) { errors.push(error); } + cleanupErrors = null; + } + + function fail(error: unknown): void { + if (cleanupErrors !== null) { + if (!cleanupErrors.includes(error)) cleanupErrors.push(error); + return; + } + if (terminal) return; + const errors = [error]; + shutdown(errors); + options.onFailure(collected(errors)); + } + + function wireFault(error: unknown): void { + if (serverFailure === null) { fail(error); return; } + // The Wire scope marker follows the existing denial/kick handler. Keep the + // original server payload, and retain any independent Wire cleanup errors. + const extras = error instanceof Error && 'errors' in error + ? (error as Error & { errors: readonly unknown[] }).errors.slice(1) : []; + fail(collected([serverFailure.data, ...extras])); + } + + function transportFault(error: unknown): void { + fail(serverFailure === null ? error : collected([serverFailure.data, error])); + } + + async function readyTask(task: () => Promise): Promise { + try { requireActive(); await task(); } + catch (error) { fail(error); throw error; } + } + + function start(): Promise { + if (startup !== null) return startup; + if (terminal) return Promise.reject(new Error('Local login host is stopped or terminal')); + startup = Promise.resolve().then(async () => { + requireActive(); + const context = await resolveLocalStartup(options.configOptions); + requireActive(); + options.record({ kind: 'startup-config', data: context.config }); + requireActive(); + const entry = createDiagnosticGameEntry(context.config.identity.gameid, options.record); + sources = createLocalLoginSources({ context, storage: options.storage, random: options.random, + now: options.now, sampleHost: options.sampleHost, + isSessionActive: () => busy || runtime?.state.app.phase === 'logged-in' }); + controller = new PlatformUiController({ + render: model => { + if (terminal) return; + try { options.view.render(model); } + catch (error) { fail(error); throw error; } + }, + activate: page => { + if (terminal) return; + try { + options.view.activate(page); + if (!terminal && page === 'lobby') setBusy(false); + } catch (error) { fail(error); throw error; } + }, + showOverlay: model => { + if (terminal) return; + if (model.kind === 'denial') serverFailure = { data: model.outcome.data }; + if (model.kind === 'kicked') serverFailure = { data: model.data }; + try { options.view.showOverlay(model); } + catch (error) { transportFault(error); throw error; } + if (model.kind === 'fatal') fail(model.error); + }, + }, options.scheduler, fail); + runtime = new PlatformRuntime({ gameEntry: entry, + resolveRuntimeConfig: async () => context.config, + createWireClient: () => createLocalLoginWire({ context, record: options.record, onFault: wireFault, + transportFactory: () => new LocalLoginTransport({ context, createSocket: options.createSocket, + record: options.record, onFault: transportFault }) }), + scene: controller, loadResources: () => readyTask(options.loadResources), + waitForMinimumDisplay: () => readyTask(options.waitForMinimumDisplay), + getLoginDeviceSnapshot: sources.getLoginDeviceSnapshot, + }); + controller.connect(runtime); + requireActive(); + await runtime.start(); + requireActive(); + }).catch(error => { fail(error); throw error; }); + return startup; + } + + function login(): Promise { + if (terminal) return Promise.reject(new Error('Local login host is stopped or terminal')); + if (busy && loginOperation !== null) return loginOperation; + if (runtime === null || controller === null || sources === null || !runtime.ready) { + return Promise.reject(new Error('Local login host is not ready for login')); + } + const activeSources = sources; + const activeController = controller; + loginOperation = Promise.resolve().then(async () => { + requireActive(); + const account = await activeSources.prepareLogin(); + requireActive(); + activeController.login(account); + }).catch(error => { fail(error); throw error; }); + try { setBusy(true); } catch (error) { fail(error); } + return loginOperation; + } + + return { + start, login, + stop() { + if (terminal) return; + const errors: unknown[] = []; + shutdown(errors); + if (errors.length > 0) { + const error = collected(errors); + options.onFailure(error); + throw error; + } + }, + getSnapshot: () => ({ state: runtime === null ? null : runtime.state, + ready: !terminal && runtime !== null && runtime.ready, busy }), + }; +} diff --git a/cocoscreator_projects/framework-tests/local-platform-login/host.test.ts b/cocoscreator_projects/framework-tests/local-platform-login/host.test.ts new file mode 100644 index 0000000..9b244f0 --- /dev/null +++ b/cocoscreator_projects/framework-tests/local-platform-login/host.test.ts @@ -0,0 +1,178 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { test } from 'node:test'; +import type { PageModel, OverlayModel, PageId } from '../../YouleNexus/assets/framework/presentation/ui-contracts.ts'; +import type { ResolveRuntimeConfigOptions } from '../../YouleNexus/assets/framework/config/runtime-config.ts'; + +function deferred() { + let resolve!: () => void; + let reject!: (error: unknown) => void; + const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); + return { promise, resolve, reject }; +} +async function flush() { for (let i = 0; i < 30; i++) await Promise.resolve(); } +function fixture(name = 'player-login-success.json') { + return JSON.parse(readFileSync(new URL(`../fixtures/contracts/${name}`, import.meta.url), 'utf8')); +} +class Socket { + readyState = 0; + sent: string[] = []; + closes = 0; + closeError?: Error; + onopen: (() => void) | null = null; + onclose: (() => void) | null = null; + onmessage: ((event: { data: string }) => void) | null = null; + onerror: (() => void) | null = null; + send(frame: string) { this.sent.push(frame); } + close() { this.closes++; if (this.closeError) throw this.closeError; this.readyState = 3; this.onclose?.(); } + open() { this.readyState = 1; this.onopen?.(); } + push(message: unknown) { this.onmessage?.({ data: JSON.stringify(message) }); } +} +async function setup(overrides: { config?: Partial; render?: (model: PageModel) => void; + record?: (event: { kind: string; data: unknown }) => void; onBusy?: (busy: boolean) => void } = {}) { + const api = await import('../../YouleNexus/assets/scripts/local-platform-login/local-login-host.ts') + .catch(error => assert.fail(`Local login host implementation must exist: ${String(error)}`)); + const resources = deferred(), minimum = deferred(); + const sockets: Socket[] = [], pages: PageModel[] = [], overlays: OverlayModel[] = [], activations: PageId[] = []; + const failures: unknown[] = [], busy: boolean[] = [], evidence: { kind: string; data: unknown }[] = []; + const values = new Map(), frames = new Set<() => void>(); + let fetches = 0; + const host = api.createLocalLoginHost({ + configOptions: { mode: 'debug', hostKind: 'h5', win: {}, search: '?profile=local&version=731', + fetcher: { async fetch(): Promise { fetches++; throw new Error('unexpected fetch'); } }, ...overrides.config }, + storage: { getItem: key => values.get(key) ?? null, setItem: (key, value) => { values.set(key, value); } }, + random: () => .5, now: () => 123456, sampleHost: () => ({ location: null }), + view: { render: model => { overrides.render?.(model); pages.push(model); }, + activate: page => { activations.push(page); }, showOverlay: model => { overlays.push(model); } }, + scheduler: { request: callback => { frames.add(callback); return () => { frames.delete(callback); }; } }, + loadResources: () => resources.promise, waitForMinimumDisplay: () => minimum.promise, + createSocket: () => { const socket = new Socket(); sockets.push(socket); return socket as unknown as WebSocket; }, + record: event => { evidence.push(event); overrides.record?.(event); }, + onBusy: value => { busy.push(value); overrides.onBusy?.(value); }, onFailure: error => { failures.push(error); }, + }); + const draw = () => { for (const frame of [...frames]) { frames.delete(frame); frame(); } }; + const ready = async () => { const starting = host.start(); resources.resolve(); minimum.resolve(); await flush(); sockets[0]!.open(); await starting; draw(); }; + return { host, resources, minimum, sockets, pages, overlays, activations, failures, busy, evidence, values, frames, draw, ready, fetches: () => fetches }; +} + +for (const missing of ['resources', 'minimum', 'socket'] as const) test(`login waits for ${missing}, and start composes only once`, async t => { + const h = await setup(); t.after(() => h.host.stop()); + const starting = h.host.start(); const repeated = h.host.start(); + assert.strictEqual(starting, repeated); + await flush(); + if (missing !== 'resources') h.resources.resolve(); + if (missing !== 'minimum') h.minimum.resolve(); + if (missing !== 'socket') h.sockets[0]!.open(); + await flush(); + assert.equal(h.host.getSnapshot().ready, false); + await assert.rejects(h.host.login(), /ready/i); + assert.equal(h.values.size, 0); assert.deepEqual(h.sockets[0]!.sent, []); + h.resources.resolve(); h.minimum.resolve(); if (missing === 'socket') h.sockets[0]!.open(); + await starting; + assert.equal(h.host.getSnapshot().ready, true); assert.equal(h.fetches(), 0); + assert.equal(h.sockets.length, 1); + assert.equal(h.evidence.filter(event => event.kind === 'startup-config').length, 1); + await h.host.login(); + assert.equal(JSON.parse(h.sockets[0]!.sent[0]!).data.version, 731); +}); + +test('duplicate clicks send once; response updates the sole Store and lobby projection and clears busy', async t => { + const h = await setup(); t.after(() => h.host.stop()); await h.ready(); + const first = h.host.login(), second = h.host.login(); assert.strictEqual(first, second); + assert.equal(h.host.getSnapshot().busy, true); + await first; await h.host.login(); assert.equal(h.sockets[0]!.sent.length, 1); + assert.deepEqual(h.busy, [true]); + h.sockets[0]!.push(fixture()); h.draw(); + assert.equal(h.host.getSnapshot().state?.app.phase, 'logged-in'); + assert.equal(h.host.getSnapshot().state?.room.kind, 'outside'); + assert.deepEqual(h.pages[h.pages.length - 1], { page: 'lobby', self: { playerid: 430511, nickname: '测试号', avatar: 'http://a', bean: 0 } }); + assert.deepEqual(h.busy, [true, false]); + const before = h.pages.length; h.host.stop(); h.draw(); assert.equal(h.pages.length, before); +}); + +for (const rpc of ['player_login', 'kick_server']) test(`${rpc} retains original server reason through terminal cleanup`, async t => { + const h = await setup(); t.after(() => h.host.stop()); await h.ready(); await h.host.login(); + const data = { state: 17, msg: '原始服务器原因' }; + h.sockets[0]!.push({ route: 'agent', rpc, data }); + assert.equal(h.failures.length, 1); assert.deepEqual(h.failures[0], data); + assert.equal(h.host.getSnapshot().ready, false); assert.equal(h.host.getSnapshot().busy, false); + assert.equal(h.sockets[0]!.closes, 1); + await assert.rejects(h.host.login(), /stop|terminal/i); +}); + +test('room recovery is refused before any player or room state enters Store', async t => { + const h = await setup(); t.after(() => h.host.stop()); await h.ready(); await h.host.login(); + h.sockets[0]!.push(fixture('player-login-room.json')); + assert.match(String(h.failures[0]), /room recovery/); + assert.equal(h.host.getSnapshot().state?.players.selfPlayerId, null); + assert.equal(h.host.getSnapshot().state?.room.kind, 'outside'); + assert.equal(h.pages.some(model => model.page === 'lobby'), false); +}); + +test('corrupt account cache prevents sending and preserves the cache', async t => { + const h = await setup(); t.after(() => h.host.stop()); await h.ready(); + // Discover the authority-owned key from a prior independent local login. + const prior = await setup(); t.after(() => prior.host.stop()); await prior.ready(); await prior.host.login(); + for (const [key, value] of prior.values) h.values.set(key, value); + const accountKey = [...h.values].find(([, value]) => value.includes('account'))![0]; + h.values.set(accountKey, '{broken'); + await assert.rejects(h.host.login(), /invalid JSON/); + assert.equal(h.sockets[0]!.sent.length, 0); assert.equal(h.values.get(accountKey), '{broken'); + assert.equal(h.failures.length, 1); +}); + +for (const phase of ['config', 'resources', 'prepare'] as const) test(`stop during ${phase} suppresses late connection, send and render`, async () => { + const h = await setup(); + let operation: Promise; + if (phase === 'prepare') { await h.ready(); operation = h.host.login(); } + else { operation = h.host.start(); if (phase === 'resources') await flush(); } + const settled = operation.catch(() => {}); + // Let config resolution / account preparation begin before terminal stop. + if (phase !== 'resources') await Promise.resolve(); + h.host.stop(); const pageCount = h.pages.length; + h.resources.resolve(); h.minimum.resolve(); await settled; await flush(); h.draw(); + assert.equal(h.pages.length, pageCount); assert.equal(h.frames.size, 0); + assert.equal(h.sockets.flatMap(socket => socket.sent).length, 0); + if (phase === 'config') assert.equal(h.sockets.length, 0); + assert.deepEqual(h.failures, []); +}); + +for (const config of [{ search: '?profile=prod' }, { hostKind: 'native-settings' as const }]) test('invalid local input cannot create an account or connection', async () => { + const h = await setup({ config }); + await assert.rejects(h.host.start(), /local startup/); + assert.equal(h.values.size, 0); assert.equal(h.sockets.length, 0); assert.equal(h.failures.length, 1); +}); + +test('resource failure is reported with original object after shutdown', async () => { + const h = await setup(); const error = new Error('resource dependency missing'); + const starting = h.host.start(); await flush(); h.resources.reject(error); h.minimum.resolve(); + await assert.rejects(starting); + assert.strictEqual(h.failures[0], error); assert.equal(h.sockets[0]!.closes, 1); +}); + +test('observer and socket cleanup failures both survive terminal reporting', async t => { + const observer = new Error('render observer failed'), cleanup = new Error('socket close failed'); + const h = await setup({ render: model => { if (model.page === 'lobby') throw observer; } }); + t.after(() => h.host.stop()); await h.ready(); await h.host.login(); h.sockets[0]!.closeError = cleanup; + h.sockets[0]!.push(fixture()); + const contains = (value: unknown, target: unknown): boolean => value === target || + (!!value && typeof value === 'object' && 'errors' in value && (value.errors as unknown[]).some(error => contains(error, target))); + assert.equal(contains(h.failures[0], observer), true); assert.equal(contains(h.failures[0], cleanup), true); +}); + +test('resource error remains primary when closing its socket also fails', async () => { + const h = await setup(); const primary = new Error('resource failed'), cleanup = new Error('close failed'); + const starting = h.host.start(); await flush(); h.sockets[0]!.closeError = cleanup; + h.resources.reject(primary); h.minimum.resolve(); await assert.rejects(starting); + const errors = (h.failures[0] as { errors: unknown[] }).errors; + assert.strictEqual(errors[0], primary); assert.ok(errors.includes(cleanup)); +}); + +test('kick data remains primary when closing its socket also fails', async () => { + const h = await setup(); await h.ready(); await h.host.login(); + const cleanup = new Error('kick close failed'); h.sockets[0]!.closeError = cleanup; + const data = { msg: 'original kick reason' }; + h.sockets[0]!.push({ route: 'agent', rpc: 'kick_server', data }); + const errors = (h.failures[0] as { errors: unknown[] }).errors; + assert.deepEqual(errors[0], data); assert.ok(errors.includes(cleanup)); +});