Extract PromptContext from GameHost into separate module

The prompt system is now managed by a dedicated PromptContext instead of
being embedded in GameHost. This simplifies the API by removing
individual prompt signals and consolidating prompt logic into a reusable
context that can be shared between components.
This commit is contained in:
hyper 2026-04-23 15:07:52 +08:00
parent 59b99d2042
commit b009189b9a
5 changed files with 58 additions and 138 deletions

View File

@ -1,100 +1,40 @@
import { ReadonlySignal, Signal } from "@preact/signals-core"; import { ReadonlySignal, Signal } from "@preact/signals-core";
import { CommandSchema, CommandRegistry, PromptEvent } from "@/utils/command"; import { createPromptContext } from "@/utils/command";
import { import { createGameContext, IGameContext } from "./game";
createGameCommandRegistry, import { PromptContext } from "@/utils/command/command-prompt";
createGameContext,
IGameContext,
PromptDef,
} from "./game";
export type GameHostStatus = "created" | "running" | "disposed"; export type GameHostStatus = "created" | "running" | "disposed";
export class GameHost< export class GameHost<TModule extends GameModule> {
TState extends Record<string, unknown>, readonly state: ReadonlySignal<GameState<TModule>>;
TResult = unknown,
TModule extends GameModule<TState, TResult> = GameModule<TState, TResult>,
> {
readonly state: ReadonlySignal<TState>;
readonly status: ReadonlySignal<GameHostStatus>; readonly status: ReadonlySignal<GameHostStatus>;
readonly activePromptSchema: ReadonlySignal<CommandSchema | null>; readonly prompts: PromptContext;
readonly activePromptPlayer: ReadonlySignal<string | null>;
readonly activePromptHint: ReadonlySignal<string | null>;
private _context: IGameContext<TState>; private _context: IGameContext<GameState<TModule>>;
private _start: (ctx: IGameContext<TState>) => Promise<TResult>; private _start: (
ctx: IGameContext<GameState<TModule>>,
) => Promise<GameResult<TModule>>;
private _status: Signal<GameHostStatus>; private _status: Signal<GameHostStatus>;
private _activePromptSchema: Signal<CommandSchema | null>; private _createInitialState: () => GameState<TModule>;
private _activePromptPlayer: Signal<string | null>;
private _activePromptHint: Signal<string | null>;
private _createInitialState: () => TState;
private _eventListeners: Map<"start" | "dispose", Set<() => void>>; private _eventListeners: Map<"start" | "dispose", Set<() => void>>;
private _isDisposed = false; private _isDisposed = false;
constructor(public readonly gameModule: TModule) { constructor(public readonly gameModule: TModule) {
const { createInitialState, registry, start } = gameModule; const { createInitialState, start } = gameModule as unknown as GameModule<
GameState<TModule>,
GameResult<TModule>
>;
this._createInitialState = createInitialState; this._createInitialState = createInitialState;
this._eventListeners = new Map(); this._eventListeners = new Map();
this.prompts = createPromptContext();
const initialState = createInitialState(); const initialState = createInitialState();
this._context = createGameContext( this._context = createGameContext(this.prompts, initialState);
registry ?? createGameCommandRegistry(),
initialState,
);
this._start = start; this._start = start;
this.state = this._context._state; this.state = this._context._state;
this._status = new Signal<GameHostStatus>("created"); this._status = new Signal<GameHostStatus>("created");
this.status = this._status; this.status = this._status;
this._activePromptSchema = new Signal<CommandSchema | null>(null);
this.activePromptSchema = this._activePromptSchema;
this._activePromptPlayer = new Signal<string | null>(null);
this.activePromptPlayer = this._activePromptPlayer;
this._activePromptHint = new Signal<string | null>(null);
this.activePromptHint = this._activePromptHint;
this._setupPromptTracking();
}
private _setupPromptTracking() {
let currentPromptEvent: PromptEvent | null = null;
this._context._commands.on("prompt", (e) => {
currentPromptEvent = e as PromptEvent;
this._activePromptSchema.value = currentPromptEvent.schema;
this._activePromptPlayer.value = currentPromptEvent.currentPlayer;
this._activePromptHint.value = currentPromptEvent.hintText || null;
});
this._context._commands.on("promptEnd", () => {
currentPromptEvent = null;
this._activePromptSchema.value = null;
this._activePromptPlayer.value = null;
this._activePromptHint.value = null;
});
// Initial state
this._activePromptSchema.value = null;
this._activePromptPlayer.value = null;
this._activePromptHint.value = null;
}
tryInput(input: string): string | null {
if (this._isDisposed) {
return "GameHost is disposed";
}
return this._context._commands._tryCommit(input);
}
tryAnswerPrompt<TArgs extends any[]>(def: PromptDef<TArgs>, ...args: TArgs) {
return this._context._commands._tryCommit({
name: def.schema.name,
params: args,
options: {},
flags: {},
});
} }
/** /**
@ -113,12 +53,12 @@ export class GameHost<
this._context._state.clearInterruptions(); this._context._state.clearInterruptions();
} }
start(seed?: number): Promise<TResult> { start(seed?: number): Promise<GameResult<TModule>> {
if (this._isDisposed) { if (this._isDisposed) {
throw new Error("GameHost is disposed"); throw new Error("GameHost is disposed");
} }
this._context._commands._cancel(); this.prompts.reset();
const initialState = this._createInitialState(); const initialState = this._createInitialState();
this._context._state.value = initialState as any; this._context._state.value = initialState as any;
@ -139,7 +79,7 @@ export class GameHost<
} }
this._isDisposed = true; this._isDisposed = true;
this._context._commands._cancel(); this.prompts.reset();
this._status.value = "disposed"; this._status.value = "disposed";
// Emit dispose event BEFORE clearing listeners // Emit dispose event BEFORE clearing listeners
@ -169,18 +109,21 @@ export class GameHost<
} }
export type GameModule< export type GameModule<
TState extends Record<string, unknown>, TState extends Record<string, unknown> = Record<string, unknown>,
TResult = unknown, TResult = unknown,
> = { > = {
registry?: CommandRegistry<IGameContext<TState>>;
createInitialState: () => TState; createInitialState: () => TState;
start: (ctx: IGameContext<TState>) => Promise<TResult>; start: (ctx: IGameContext<TState>) => Promise<TResult>;
}; };
export type GameState<TModule extends GameModule> = ReturnType<
TModule["createInitialState"]
>;
export type GameResult<TModule extends GameModule> = Awaited<
ReturnType<TModule["start"]>
>;
export function createGameHost< export function createGameHost<TModule extends GameModule>(
TState extends Record<string, unknown>, gameModule: TModule,
TResult = unknown, ): GameHost<TModule> {
TModule extends GameModule<TState, TResult> = GameModule<TState, TResult>,
>(gameModule: TModule): GameHost<TState, TResult> {
return new GameHost(gameModule); return new GameHost(gameModule);
} }

View File

@ -1,40 +1,29 @@
import { MutableSignal, mutableSignal } from "@/utils/mutable-signal"; import { MutableSignal, mutableSignal } from "@/utils/mutable-signal";
import { import { CommandSchema, parseCommandSchema, PromptDef } from "@/utils/command";
Command,
CommandRegistry,
CommandResult,
CommandRunnerContextExport,
CommandSchema,
createCommandRegistry,
createCommandRunnerContext,
parseCommandSchema,
} from "@/utils/command";
import { PromptValidator } from "@/utils/command/command-runner"; import { PromptValidator } from "@/utils/command/command-runner";
import { Mulberry32RNG, ReadonlyRNG, RNG } from "@/utils/rng"; import { Mulberry32RNG, ReadonlyRNG, RNG } from "@/utils/rng";
import { PromptContext } from "@/utils/command/command-prompt";
export interface IGameContext<TState extends Record<string, unknown> = {}> { export interface IGameContext<TState extends Record<string, unknown> = {}> {
get value(): TState; get value(): TState;
get rng(): ReadonlyRNG; get rng(): ReadonlyRNG;
produce(fn: (draft: TState) => undefined): void; produce(fn: (draft: TState) => undefined): void;
produceAsync(fn: (draft: TState) => undefined): Promise<void>; produceAsync(fn: (draft: TState) => undefined): Promise<void>;
run<T>(input: string): Promise<CommandResult<T>>;
runParsed<T>(command: Command): Promise<CommandResult<T>>;
prompt: <TResult, TArgs extends any[] = any[]>( prompt: <TResult, TArgs extends any[] = any[]>(
def: PromptDef<TArgs>, def: PromptDef<TArgs>,
validator: PromptValidator<TResult, TArgs>, validator: PromptValidator<TResult, TArgs>,
currentPlayer?: string | null, player?: string,
) => Promise<TResult>; ) => Promise<TResult>;
// test only // test only
_state: MutableSignal<TState>; _state: MutableSignal<TState>;
_commands: CommandRunnerContextExport<IGameContext<TState>>;
_rng: RNG; _rng: RNG;
} }
export type IGameContextExport<TState extends Record<string, unknown> = {}> = export type IGameContextExport<TState extends Record<string, unknown> = {}> =
Omit<IGameContext<TState>, "_state" | "_commands" | "_rng">; Omit<IGameContext<TState>, "_state" | "_commands" | "_rng">;
export function createGameContext<TState extends Record<string, unknown> = {}>( export function createGameContext<TState extends Record<string, unknown> = {}>(
commandRegistry: CommandRegistry<IGameContext<TState>>, promptContext: PromptContext,
initialState?: TState | (() => TState), initialState?: TState | (() => TState),
): IGameContext<TState> { ): IGameContext<TState> {
const stateValue = const stateValue =
@ -42,7 +31,7 @@ export function createGameContext<TState extends Record<string, unknown> = {}>(
? initialState() ? initialState()
: (initialState ?? ({} as TState)); : (initialState ?? ({} as TState));
const state = mutableSignal(stateValue); const state = mutableSignal(stateValue);
let commands: CommandRunnerContextExport<IGameContext<TState>> = null as any; const { prompt } = promptContext;
const context: IGameContext<TState> = { const context: IGameContext<TState> = {
get value(): TState { get value(): TState {
@ -57,38 +46,15 @@ export function createGameContext<TState extends Record<string, unknown> = {}>(
produceAsync(fn: (draft: TState) => undefined) { produceAsync(fn: (draft: TState) => undefined) {
return state.produceAsync(fn); return state.produceAsync(fn);
}, },
run<T>(input: string) { prompt,
return commands.run<T>(input);
},
runParsed<T>(command: Command) {
return commands.runParsed<T>(command);
},
prompt(def, validator, currentPlayer) {
return commands.prompt(
def.schema,
validator,
def.hintText,
currentPlayer,
);
},
_state: state, _state: state,
_commands: commands,
_rng: new Mulberry32RNG(), _rng: new Mulberry32RNG(),
}; };
context._commands = commands = createCommandRunnerContext(
commandRegistry,
context,
);
return context; return context;
} }
export type PromptDef<TArgs extends any[] = any[]> = {
schema: CommandSchema;
hintText?: string;
};
export function createPromptDef<TArgs extends any[] = any[]>( export function createPromptDef<TArgs extends any[] = any[]>(
schema: CommandSchema | string, schema: CommandSchema | string,
hintText?: string, hintText?: string,
@ -96,9 +62,3 @@ export function createPromptDef<TArgs extends any[] = any[]>(
schema = typeof schema === "string" ? parseCommandSchema(schema) : schema; schema = typeof schema === "string" ? parseCommandSchema(schema) : schema;
return { schema, hintText }; return { schema, hintText };
} }
export function createGameCommandRegistry<
TState extends Record<string, unknown> = {},
>() {
return createCommandRegistry<IGameContext<TState>>();
}

View File

@ -5,7 +5,7 @@
// Core types // Core types
export type { IGameContext } from "./core/game"; export type { IGameContext } from "./core/game";
export { createGameCommandRegistry, createPromptDef } from "./core/game"; export { createPromptDef } from "./core/game";
export type { GameHost, GameHostStatus, GameModule } from "./core/game-host"; export type { GameHost, GameHostStatus, GameModule } from "./core/game-host";
export { createGameHost } from "./core/game-host"; export { createGameHost } from "./core/game-host";

View File

@ -4,7 +4,7 @@ import { CommandSchema } from "./types";
export interface PromptDef<TArgs extends any[]> { export interface PromptDef<TArgs extends any[]> {
schema: CommandSchema; schema: CommandSchema;
hint?: string; hintText?: string;
} }
export interface PromptCall<TArgs extends any[] = unknown[], TRes = unknown> { export interface PromptCall<TArgs extends any[] = unknown[], TRes = unknown> {
@ -26,6 +26,7 @@ export type PromptTryResult =
ok: true; ok: true;
}; };
export type PromptContext = ReturnType<typeof createPromptContext>;
export function createPromptContext() { export function createPromptContext() {
const map = new Map<string, PromptCall>(); const map = new Map<string, PromptCall>();
const handleCall = createMiddlewareChain(async (call: PromptCall) => { const handleCall = createMiddlewareChain(async (call: PromptCall) => {
@ -44,6 +45,7 @@ export function createPromptContext() {
}); });
function tryCommit<TArgs extends any[]>( function tryCommit<TArgs extends any[]>(
def: PromptDef<TArgs>,
player: string, player: string,
...args: TArgs ...args: TArgs
): PromptTryResult { ): PromptTryResult {
@ -83,7 +85,14 @@ export function createPromptContext() {
reject: reject!, reject: reject!,
promise, promise,
} as PromptCall<unknown[], unknown>; } as PromptCall<unknown[], unknown>;
return await handleCall.execute(call); return (await handleCall.execute(call)) as TRes;
}
function reset() {
for (const call of map.values()) {
call.reject("Prompt Reset");
}
map.clear();
} }
return { return {
@ -91,5 +100,6 @@ export function createPromptContext() {
tryCommit, tryCommit,
cancel, cancel,
handleCall, handleCall,
reset,
}; };
} }

View File

@ -1,7 +1,14 @@
import { Signal, SignalOptions } from "@preact/signals-core"; import { Signal, SignalOptions } from "@preact/signals-core";
import { create } from "mutative"; import { create } from "mutative";
export class MutableSignal<T> extends Signal<T> { export interface MutableSignal<T> extends Signal<T> {
produce(fn: (draft: T) => undefined): void;
addInterruption(promise: Promise<void>): void;
clearInterruptions(): void;
produceAsync(fn: (draft: T) => undefined): Promise<void>;
}
class MutableSignalImpl<T> extends Signal<T> {
private _interruptions: Promise<void>[] = []; private _interruptions: Promise<void>[] = [];
public constructor(t?: T, options?: SignalOptions<T>) { public constructor(t?: T, options?: SignalOptions<T>) {
@ -41,5 +48,5 @@ export function mutableSignal<T>(
initial?: T, initial?: T,
options?: SignalOptions<T>, options?: SignalOptions<T>,
): MutableSignal<T> { ): MutableSignal<T> {
return new MutableSignal<T>(initial, options); return new MutableSignalImpl<T>(initial, options);
} }