boardgame-core/src/utils/command/command-registry.ts

269 lines
9.0 KiB
TypeScript

import type { Command, CommandSchema } from './types';
import type {
CommandDef, CommandFunction,
CommandResult,
CommandRunner,
CommandRunnerContext,
CommandRunnerEvents,
PromptEvent,
PromptValidator
} from './command-runner';
import { parseCommand } from './command-parse';
import { applyCommandSchema } from './command-validate';
import { parseCommandSchema } from './schema-parse';
import {AsyncQueue} from "@/utils/async-queue";
type CanRunParsed = {
runParsed<T=unknown>(command: Command): Promise<CommandResult<T>>,
}
export class CommandRegistry<TContext> extends Map<string, CommandRunner<TContext>>{
register<TFunc extends CommandFunction<TContext>>({schema,run}: CommandDef<TContext, TFunc>) {
const parsedSchema = typeof schema === 'string' ? parseCommandSchema(schema) : schema;
registerCommand(this, {
schema: parsedSchema,
async run(this: CommandRunnerContext<TContext>, command: Command){
const params = command.params;
return await run.call(this.context, this.context, ...params);
},
});
type TParams = TFunc extends (ctx: TContext, ...args: infer X) => Promise<unknown> ? X : null;
type TResult = TFunc extends (ctx: TContext, ...args: any[]) => Promise<infer X> ? X : null;
return async function(ctx: TContext & CanRunParsed, ...args: TParams){
const result: CommandResult<TResult> = await ctx.runParsed({
options: {},
params: args,
flags: {},
name: parsedSchema.name,
});
if(result.success) return result.result;
throw new Error(result.error);
}
}
}
export function createCommandRegistry<TContext>(): CommandRegistry<TContext> {
return new CommandRegistry();
}
export function registerCommand<TContext, TResult>(
registry: CommandRegistry<TContext>,
runner: CommandRunner<TContext, TResult>
): void {
registry.set(runner.schema.name, runner as CommandRunner<TContext, unknown>);
}
export function unregisterCommand<TContext>(
registry: CommandRegistry<TContext>,
name: string
): void {
registry.delete(name);
}
export function hasCommand<TContext>(
registry: CommandRegistry<TContext>,
name: string
): boolean {
return registry.has(name);
}
export function getCommand<TContext>(
registry: CommandRegistry<TContext>,
name: string
): CommandRunner<TContext, unknown> | undefined {
return registry.get(name);
}
type PromptListener = (e: PromptEvent) => void;
type PromptEndListener = () => void;
export type CommandRunnerContextExport<TContext> = CommandRunnerContext<TContext> & {
registry: CommandRegistry<TContext>;
promptQueue: AsyncQueue<PromptEvent>;
_activePrompt: PromptEvent | null;
_tryCommit: (commandOrInput: Command | string) => string | null;
_cancel: (reason?: string) => void;
_pendingInput: string | null;
};
export function createCommandRunnerContext<TContext>(
registry: CommandRegistry<TContext>,
context: TContext
): CommandRunnerContextExport<TContext> {
const promptListeners = new Set<PromptListener>();
const promptEndListeners = new Set<PromptEndListener>();
const emitPromptEnd = () => {
for (const listener of promptEndListeners) {
listener();
}
};
const on = <T extends keyof CommandRunnerEvents>(_event: T, listener: (e: CommandRunnerEvents[T]) => void) => {
if (_event === 'prompt') {
promptListeners.add(listener as PromptListener);
} else {
promptEndListeners.add(listener as PromptEndListener);
}
};
const off = <T extends keyof CommandRunnerEvents>(_event: T, listener: (e: CommandRunnerEvents[T]) => void) => {
if (_event === 'prompt') {
promptListeners.delete(listener as PromptListener);
} else {
promptEndListeners.delete(listener as PromptEndListener);
}
};
let activePrompt: PromptEvent | null = null;
const tryCommit = (commandOrInput: Command | string) => {
if (activePrompt) {
const result = activePrompt.tryCommit(commandOrInput);
if (result === null) {
activePrompt = null;
emitPromptEnd();
}
return result;
}
return 'No active prompt';
};
const cancel = (reason?: string) => {
if (activePrompt) {
activePrompt.cancel(reason);
activePrompt = null;
emitPromptEnd();
}
};
const prompt = <T>(
schema: CommandSchema | string,
validator: PromptValidator<T>,
currentPlayer?: string | null
): Promise<T> => {
const resolvedSchema = typeof schema === 'string' ? parseCommandSchema(schema) : schema;
return new Promise((resolve, reject) => {
const tryCommit = (commandOrInput: Command | string) => {
const command = typeof commandOrInput === 'string' ? parseCommand(commandOrInput) : commandOrInput;
const schemaResult = applyCommandSchema(command, resolvedSchema);
if (!schemaResult.valid) {
return schemaResult.errors.join('; ');
}
try{
const result = validator(schemaResult.command);
resolve(result);
return null;
}catch(e){
if(typeof e === 'string')
return e;
else
throw e;
}
};
const cancel = (reason?: string) => {
activePrompt = null;
emitPromptEnd();
reject(new Error(reason ?? 'Cancelled'));
};
activePrompt = { schema: resolvedSchema, currentPlayer: currentPlayer ?? null, tryCommit, cancel };
const event: PromptEvent = { schema: resolvedSchema, currentPlayer: currentPlayer ?? null, tryCommit, cancel };
for (const listener of promptListeners) {
listener(event);
}
});
};
const runnerCtx: CommandRunnerContextExport<TContext> = {
registry,
context,
run: <T=unknown>(input: string) => runCommandWithContext(runnerCtx, input) as Promise<CommandResult<T>>,
runParsed: <T=unknown>(command: Command) => runCommandParsedWithContext(runnerCtx, command) as Promise<CommandResult<T>>,
prompt,
on,
off,
_activePrompt: null,
_tryCommit: tryCommit,
_cancel: cancel,
_pendingInput: null,
promptQueue: null!
};
Object.defineProperty(runnerCtx, '_activePrompt', {
get: () => activePrompt,
});
let promptQueue: AsyncQueue<PromptEvent>;
Object.defineProperty(runnerCtx, 'promptQueue', {
get(){
if (!promptQueue) {
promptQueue = new AsyncQueue();
promptListeners.add(async (event) => {
promptQueue.push(event);
});
}
return promptQueue;
}
});
return runnerCtx;
}
async function executeWithRunnerContext<TContext>(
runnerCtx: CommandRunnerContextExport<TContext>,
runner: CommandRunner<TContext, unknown>,
command: Command
): Promise<CommandResult> {
try {
const result = await runner.run.call(runnerCtx, command);
return { success: true, result };
} catch (e) {
const error = e as Error;
return { success: false, error: error.message };
}
}
export async function runCommand<TContext>(
registry: CommandRegistry<TContext>,
context: TContext,
input: string
): Promise<CommandResult> {
const runnerCtx = createCommandRunnerContext(registry, context);
return await runCommandWithContext(runnerCtx, input);
}
async function runCommandWithContext<TContext>(
runnerCtx: CommandRunnerContextExport<TContext>,
input: string
): Promise<{ success: true; result: unknown } | { success: false; error: string }> {
const command = parseCommand(input);
return await runCommandParsedWithContext(runnerCtx, command);
}
export async function runCommandParsed<TContext>(
registry: CommandRegistry<TContext>,
context: TContext,
command: Command
): Promise<CommandResult> {
const runnerCtx = createCommandRunnerContext(registry, context);
return await runCommandParsedWithContext(runnerCtx, command);
}
async function runCommandParsedWithContext<TContext>(
runnerCtx: CommandRunnerContextExport<TContext>,
command: Command
): Promise<CommandResult> {
const runner = runnerCtx.registry.get(command.name);
if (!runner) {
return { success: false, error: `Unknown command: ${command.name}` };
}
const validationResult = applyCommandSchema(command, runner.schema);
if (!validationResult.valid) {
return { success: false, error: validationResult.errors.join('; ') };
}
return await executeWithRunnerContext(runnerCtx, runner, validationResult.command);
}