feat: compose local platform login host
This commit is contained in:
+195
@@ -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<void>;
|
||||
waitForMinimumDisplay: () => Promise<void>;
|
||||
createSocket: (url: string) => WebSocket;
|
||||
record: EvidenceSink;
|
||||
onBusy: (busy: boolean) => void;
|
||||
onFailure: (error: unknown) => void;
|
||||
}
|
||||
|
||||
export interface LocalLoginHost {
|
||||
start(): Promise<void>;
|
||||
login(): Promise<void>;
|
||||
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<void> | null = null;
|
||||
let loginOperation: Promise<void> | 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<void>): Promise<void> {
|
||||
try { requireActive(); await task(); }
|
||||
catch (error) { fail(error); throw error; }
|
||||
}
|
||||
|
||||
function start(): Promise<void> {
|
||||
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<void> {
|
||||
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 }),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user