diff --git a/packages/framework/src/input/InputMapper.test.ts b/packages/framework/src/input/InputMapper.test.ts deleted file mode 100644 index 32f6c09..0000000 --- a/packages/framework/src/input/InputMapper.test.ts +++ /dev/null @@ -1,286 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { InputMapper, createInputMapper, type InputMapperOptions } from './InputMapper.js'; -import Phaser from 'phaser'; - -// Mock Phaser -function createMockScene() { - const inputEvents = new Map(); - - const mockScene = { - input: { - on: vi.fn((event: string, callback: Function) => { - if (!inputEvents.has(event)) { - inputEvents.set(event, []); - } - inputEvents.get(event)!.push(callback); - }), - off: vi.fn((event: string, callback: Function) => { - const handlers = inputEvents.get(event); - if (handlers) { - const index = handlers.indexOf(callback); - if (index > -1) { - handlers.splice(index, 1); - } - } - }), - _emit: (event: string, ...args: any[]) => { - const handlers = inputEvents.get(event); - if (handlers) { - handlers.forEach(handler => handler(...args)); - } - }, - }, - } as unknown as Phaser.Scene; - - return { - mockScene, - inputEvents, - emitPointerDown: (pointer: Partial) => { - inputEvents.get('pointerdown')?.forEach(handler => handler(pointer)); - }, - }; -} - -function createMockGameObject() { - const events = new Map(); - - const mockObj = { - setInteractive: vi.fn(), - on: vi.fn((event: string, handler: Function) => { - if (!events.has(event)) { - events.set(event, []); - } - events.get(event)!.push(handler); - }), - off: vi.fn((event: string, handler: Function) => { - const handlers = events.get(event); - if (handlers) { - const index = handlers.indexOf(handler); - if (index > -1) { - handlers.splice(index, 1); - } - } - }), - _emit: (event: string, ...args: any[]) => { - events.get(event)?.forEach(handler => handler(...args)); - }, - } as unknown as Phaser.GameObjects.GameObject; - - return { - mockObj, - emitPointerDown: () => events.get('pointerdown')?.forEach(handler => handler()), - }; -} - -describe('InputMapper', () => { - let options: InputMapperOptions; - let onSubmit: ReturnType; - - beforeEach(() => { - onSubmit = vi.fn(); - options = { - onSubmit, - }; - }); - - describe('constructor', () => { - it('should create instance with options', () => { - const { mockScene } = createMockScene(); - const mapper = new InputMapper(mockScene, options); - expect(mapper).toBeDefined(); - }); - }); - - describe('createInputMapper', () => { - it('should create instance via factory', () => { - const { mockScene } = createMockScene(); - const mapper = createInputMapper(mockScene, options); - expect(mapper).toBeDefined(); - }); - }); - - describe('mapGridClick', () => { - it('should register pointerdown listener', () => { - const { mockScene, inputEvents } = createMockScene(); - const mapper = new InputMapper(mockScene, options); - - mapper.mapGridClick( - { x: 50, y: 50 }, - { x: 100, y: 100 }, - { cols: 3, rows: 3 }, - vi.fn(() => null), - ); - - expect(mockScene.input.on).toHaveBeenCalledWith('pointerdown', expect.any(Function)); - }); - - it('should call onSubmit with command from onCellClick', () => { - const { mockScene, emitPointerDown } = createMockScene(); - const mapper = new InputMapper(mockScene, options); - const onCellClick = vi.fn((col, row) => `place ${col} ${row}`); - - mapper.mapGridClick( - { x: 50, y: 50 }, - { x: 100, y: 100 }, - { cols: 3, rows: 3 }, - onCellClick, - ); - - // Simulate pointer at position (120, 130) - // Local: (120-100, 130-100) = (20, 30) - // Cell: (floor(20/50), floor(30/50)) = (0, 0) - emitPointerDown({ x: 120, y: 130 }); - - expect(onCellClick).toHaveBeenCalledWith(0, 0); - expect(onSubmit).toHaveBeenCalledWith('place 0 0'); - }); - - it('should ignore clicks outside grid', () => { - const { mockScene, emitPointerDown } = createMockScene(); - const mapper = new InputMapper(mockScene, options); - const onCellClick = vi.fn(() => 'click'); - - mapper.mapGridClick( - { x: 50, y: 50 }, - { x: 100, y: 100 }, - { cols: 3, rows: 3 }, - onCellClick, - ); - - // Click outside grid (negative local coordinates) - emitPointerDown({ x: 50, y: 50 }); - - expect(onCellClick).not.toHaveBeenCalled(); - expect(onSubmit).not.toHaveBeenCalled(); - }); - - it('should ignore clicks outside grid bounds', () => { - const { mockScene, emitPointerDown } = createMockScene(); - const mapper = new InputMapper(mockScene, options); - const onCellClick = vi.fn(() => 'click'); - - mapper.mapGridClick( - { x: 50, y: 50 }, - { x: 100, y: 100 }, - { cols: 3, rows: 3 }, - onCellClick, - ); - - // Click at col 5 (beyond cols 0-2) - // Local X: 300-100 = 200, col = floor(200/50) = 4 - emitPointerDown({ x: 300, y: 120 }); - - expect(onCellClick).not.toHaveBeenCalled(); - }); - - it('should not call onSubmit when onCellClick returns null', () => { - const { mockScene, emitPointerDown } = createMockScene(); - const mapper = new InputMapper(mockScene, options); - const onCellClick = vi.fn(() => null); - - mapper.mapGridClick( - { x: 50, y: 50 }, - { x: 100, y: 100 }, - { cols: 3, rows: 3 }, - onCellClick, - ); - - emitPointerDown({ x: 120, y: 130 }); - - expect(onCellClick).toHaveBeenCalled(); - expect(onSubmit).not.toHaveBeenCalled(); - }); - }); - - describe('mapObjectClick', () => { - it('should set game objects as interactive', () => { - const { mockScene } = createMockScene(); - const { mockObj: obj1 } = createMockGameObject(); - const { mockObj: obj2 } = createMockGameObject(); - - const mapper = new InputMapper(mockScene, options); - - mapper.mapObjectClick([obj1, obj2], vi.fn(() => null)); - - expect(obj1.setInteractive).toHaveBeenCalledWith({ useHandCursor: true }); - expect(obj2.setInteractive).toHaveBeenCalledWith({ useHandCursor: true }); - }); - - it('should call onSubmit when object is clicked', () => { - const { mockScene } = createMockScene(); - const { mockObj, emitPointerDown } = createMockGameObject(); - const onClick = vi.fn((obj) => `clicked ${obj}`); - - const mapper = new InputMapper(mockScene, options); - - mapper.mapObjectClick([mockObj], onClick); - - emitPointerDown(); - - expect(onClick).toHaveBeenCalledWith(mockObj); - expect(onSubmit).toHaveBeenCalledWith(`clicked ${mockObj}`); - }); - - it('should skip objects without setInteractive', () => { - const { mockScene } = createMockScene(); - const nonInteractiveObj = {} as Phaser.GameObjects.GameObject; - - const mapper = new InputMapper(mockScene, options); - - // Should not throw - expect(() => { - mapper.mapObjectClick([nonInteractiveObj], vi.fn()); - }).not.toThrow(); - }); - - it('should not call onSubmit when onClick returns null', () => { - const { mockScene } = createMockScene(); - const { mockObj, emitPointerDown } = createMockGameObject(); - const onClick = vi.fn(() => null); - - const mapper = new InputMapper(mockScene, options); - - mapper.mapObjectClick([mockObj], onClick); - emitPointerDown(); - - expect(onClick).toHaveBeenCalled(); - expect(onSubmit).not.toHaveBeenCalled(); - }); - }); - - describe('destroy', () => { - it('should remove pointerdown listener from mapGridClick', () => { - const { mockScene } = createMockScene(); - const mapper = new InputMapper(mockScene, options); - - mapper.mapGridClick( - { x: 50, y: 50 }, - { x: 100, y: 100 }, - { cols: 3, rows: 3 }, - vi.fn(() => null), - ); - - mapper.destroy(); - - expect(mockScene.input.off).toHaveBeenCalledWith('pointerdown', expect.any(Function)); - }); - - it('should remove object listeners from mapObjectClick', () => { - const { mockScene } = createMockScene(); - const { mockObj } = createMockGameObject(); - - const mapper = new InputMapper(mockScene, options); - mapper.mapObjectClick([mockObj], vi.fn(() => null)); - mapper.destroy(); - - expect(mockObj.off).toHaveBeenCalledWith('pointerdown', expect.any(Function)); - }); - - it('should be safe to call without any mappings', () => { - const { mockScene } = createMockScene(); - const mapper = new InputMapper(mockScene, options); - - expect(() => mapper.destroy()).not.toThrow(); - }); - }); -}); diff --git a/packages/framework/src/input/InputMapper.ts b/packages/framework/src/input/InputMapper.ts deleted file mode 100644 index 6575b09..0000000 --- a/packages/framework/src/input/InputMapper.ts +++ /dev/null @@ -1,93 +0,0 @@ -import Phaser from 'phaser'; - -export interface InputMapperOptions { - onSubmit: (input: string) => string | null; -} - -export class InputMapper { - private scene: Phaser.Scene; - private onSubmit: (input: string) => string | null; - private pointerDownCallback: ((pointer: Phaser.Input.Pointer) => void) | null = null; - /** Track interactive objects registered via mapObjectClick for cleanup */ - private trackedObjects: Array<{ - obj: Phaser.GameObjects.GameObject; - handler: () => void; - }> = []; - - constructor(scene: Phaser.Scene, options: InputMapperOptions) { - this.scene = scene; - this.onSubmit = options.onSubmit; - } - - mapGridClick( - cellSize: { x: number; y: number }, - offset: { x: number; y: number }, - gridDimensions: { cols: number; rows: number }, - onCellClick: (col: number, row: number) => string | null, - ): void { - const pointerDown = (pointer: Phaser.Input.Pointer) => { - const localX = pointer.x - offset.x; - const localY = pointer.y - offset.y; - - if (localX < 0 || localY < 0) return; - - const col = Math.floor(localX / cellSize.x); - const row = Math.floor(localY / cellSize.y); - - if (col < 0 || col >= gridDimensions.cols || row < 0 || row >= gridDimensions.rows) return; - - const cmd = onCellClick(col, row); - if (cmd) { - this.onSubmit(cmd); - } - }; - - this.pointerDownCallback = pointerDown; - this.scene.input.on('pointerdown', pointerDown); - } - - mapObjectClick( - gameObjects: Phaser.GameObjects.GameObject[], - onClick: (obj: T) => string | null, - ): void { - for (const obj of gameObjects) { - if ('setInteractive' in obj && typeof (obj as any).setInteractive === 'function') { - const interactiveObj = obj as any; - interactiveObj.setInteractive({ useHandCursor: true }); - - const handler = () => { - const cmd = onClick(obj as unknown as T); - if (cmd) { - this.onSubmit(cmd); - } - }; - - interactiveObj.on('pointerdown', handler); - this.trackedObjects.push({ obj, handler }); - } - } - } - - destroy(): void { - // Remove global pointerdown listener from mapGridClick - if (this.pointerDownCallback) { - this.scene.input.off('pointerdown', this.pointerDownCallback); - this.pointerDownCallback = null; - } - - // Remove per-object pointerdown listeners from mapObjectClick - for (const { obj, handler } of this.trackedObjects) { - if ('off' in obj && typeof (obj as any).off === 'function') { - (obj as any).off('pointerdown', handler); - } - } - this.trackedObjects = []; - } -} - -export function createInputMapper( - scene: Phaser.Scene, - options: InputMapperOptions, -): InputMapper { - return new InputMapper(scene, options); -} diff --git a/packages/framework/src/input/PromptHandler.test.ts b/packages/framework/src/input/PromptHandler.test.ts deleted file mode 100644 index e02c398..0000000 --- a/packages/framework/src/input/PromptHandler.test.ts +++ /dev/null @@ -1,270 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { PromptHandler, type PromptHandlerOptions } from './PromptHandler.js'; -import type { PromptEvent } from 'boardgame-core'; - -// 内联 AsyncQueue 实现用于测试(避免导入 boardgame-core 内部模块) -class AsyncQueue { - private items: T[] = []; - private resolvers: ((value: T) => void)[] = []; - - push(item: T): void { - if (this.resolvers.length > 0) { - const resolve = this.resolvers.shift()!; - resolve(item); - } else { - this.items.push(item); - } - } - - pushAll(items: Iterable): void { - for (const item of items) { - this.push(item); - } - } - - async pop(): Promise { - if (this.items.length > 0) { - return this.items.shift()!; - } - return new Promise((resolve) => { - this.resolvers.push(resolve); - }); - } - - get length(): number { - return this.items.length - this.resolvers.length; - } -} - -// Mock types -interface MockCommandContext { - commands: { - promptQueue: AsyncQueue; - }; -} - -function createMockPromptEvent( - schema: any = { name: 'test', params: [] }, - tryCommitImpl?: (input: string | any) => string | null, -): PromptEvent { - return { - schema, - tryCommit: tryCommitImpl ?? vi.fn(() => null), - cancel: vi.fn(), - }; -} - -function createMockCommands(): MockCommandContext['commands'] { - return { - promptQueue: new AsyncQueue(), - }; -} - -describe('PromptHandler', () => { - let options: PromptHandlerOptions; - let mockCommands: MockCommandContext['commands']; - let onPrompt: ReturnType; - let onCancel: ReturnType; - - beforeEach(() => { - onPrompt = vi.fn(); - onCancel = vi.fn(); - mockCommands = createMockCommands(); - options = { - commands: mockCommands as any, - onPrompt, - onCancel, - }; - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - describe('constructor', () => { - it('should create instance with options', () => { - const handler = new PromptHandler(options); - expect(handler).toBeDefined(); - }); - }); - - describe('createPromptHandler', () => { - it('should create instance via factory', async () => { - const { createPromptHandler } = await import('./PromptHandler.js'); - const handler = createPromptHandler(options); - expect(handler).toBeDefined(); - }); - }); - - describe('start', () => { - it('should listen for prompts when started', async () => { - const handler = new PromptHandler(options); - handler.start(); - - const mockEvent = createMockPromptEvent(); - mockCommands.promptQueue.push(mockEvent); - - // Wait for async operation - await new Promise(resolve => setTimeout(resolve, 10)); - - expect(onPrompt).toHaveBeenCalledWith(mockEvent); - }); - - it('should not listen when not started', async () => { - const handler = new PromptHandler(options); - // Don't call start() - - const mockEvent = createMockPromptEvent(); - mockCommands.promptQueue.push(mockEvent); - - await new Promise(resolve => setTimeout(resolve, 10)); - - expect(onPrompt).not.toHaveBeenCalled(); - }); - }); - - describe('submit', () => { - it('should submit to active prompt successfully', async () => { - const handler = new PromptHandler(options); - handler.start(); - - const mockEvent = createMockPromptEvent(); - mockCommands.promptQueue.push(mockEvent); - - await new Promise(resolve => setTimeout(resolve, 10)); - - const result = handler.submit('test input'); - - expect(mockEvent.tryCommit).toHaveBeenCalledWith('test input'); - expect(result).toBeNull(); - }); - - it('should return error when submit fails', async () => { - const mockEvent = createMockPromptEvent( - { name: 'test', params: [] }, - vi.fn(() => 'Invalid input'), - ); - - const handler = new PromptHandler(options); - handler.start(); - - mockCommands.promptQueue.push(mockEvent); - await new Promise(resolve => setTimeout(resolve, 10)); - - const result = handler.submit('bad input'); - - expect(result).toBe('Invalid input'); - expect(onPrompt).toHaveBeenCalledWith(mockEvent); - }); - - it('should store input as pending when no active prompt', () => { - const handler = new PromptHandler(options); - // Don't start, so no active prompt - - const result = handler.submit('pending input'); - - expect(result).toBeNull(); - }); - - it('should auto-submit pending input when new prompt arrives', async () => { - const tryCommitFn = vi.fn(() => null); - const mockEvent = createMockPromptEvent( - { name: 'test', params: [] }, - tryCommitFn, - ); - - const handler = new PromptHandler(options); - - // Submit before starting (pending input) - handler.submit('pending input'); - - // Start and push event - handler.start(); - mockCommands.promptQueue.push(mockEvent); - - await new Promise(resolve => setTimeout(resolve, 10)); - - expect(tryCommitFn).toHaveBeenCalledWith('pending input'); - expect(onPrompt).not.toHaveBeenCalled(); // Auto-submitted, no UI needed - }); - }); - - describe('cancel', () => { - it('should cancel active prompt', async () => { - const mockEvent = createMockPromptEvent(); - const handler = new PromptHandler(options); - handler.start(); - - mockCommands.promptQueue.push(mockEvent); - await new Promise(resolve => setTimeout(resolve, 10)); - - handler.cancel('user cancelled'); - - expect(mockEvent.cancel).toHaveBeenCalledWith('user cancelled'); - expect(onCancel).toHaveBeenCalledWith('user cancelled'); - }); - - it('should call onCancel without active prompt', () => { - const handler = new PromptHandler(options); - handler.cancel('no prompt'); - - expect(onCancel).toHaveBeenCalledWith('no prompt'); - }); - }); - - describe('stop', () => { - it('should stop listening for new prompts after stop', async () => { - const handler = new PromptHandler(options); - handler.start(); - - // Push first event, it should be handled - const mockEvent1 = createMockPromptEvent(); - mockCommands.promptQueue.push(mockEvent1); - - await new Promise(resolve => setTimeout(resolve, 10)); - expect(onPrompt).toHaveBeenCalledWith(mockEvent1); - onPrompt.mockClear(); - - // Now stop - handler.stop(); - - // Push another event, should not be handled - const mockEvent2 = createMockPromptEvent(); - mockCommands.promptQueue.push(mockEvent2); - - await new Promise(resolve => setTimeout(resolve, 10)); - - // The second event should not trigger onPrompt - // (Note: due to async nature, we check it wasn't called AFTER stop) - expect(onPrompt).not.toHaveBeenCalledWith(mockEvent2); - }); - - it('should cancel active prompt on stop', async () => { - const mockEvent = createMockPromptEvent(); - const handler = new PromptHandler(options); - handler.start(); - - mockCommands.promptQueue.push(mockEvent); - await new Promise(resolve => setTimeout(resolve, 10)); - - handler.stop(); - - expect(mockEvent.cancel).toHaveBeenCalledWith('Handler stopped'); - }); - }); - - describe('destroy', () => { - it('should call stop', async () => { - const mockEvent = createMockPromptEvent(); - const handler = new PromptHandler(options); - handler.start(); - - mockCommands.promptQueue.push(mockEvent); - await new Promise(resolve => setTimeout(resolve, 10)); - - handler.destroy(); - - expect(mockEvent.cancel).toHaveBeenCalledWith('Handler stopped'); - }); - }); -}); diff --git a/packages/framework/src/input/PromptHandler.ts b/packages/framework/src/input/PromptHandler.ts deleted file mode 100644 index de0feea..0000000 --- a/packages/framework/src/input/PromptHandler.ts +++ /dev/null @@ -1,102 +0,0 @@ -import type { IGameContext, PromptEvent } from 'boardgame-core'; - -export interface PromptHandlerOptions { - commands: IGameContext['commands']; - onPrompt: (prompt: PromptEvent) => void; - onCancel: (reason?: string) => void; -} - -export class PromptHandler { - private commands: IGameContext['commands']; - private onPrompt: (prompt: PromptEvent) => void; - private onCancel: (reason?: string) => void; - private activePrompt: PromptEvent | null = null; - private isListening = false; - private pendingInput: string | null = null; - - constructor(options: PromptHandlerOptions) { - this.commands = options.commands; - this.onPrompt = options.onPrompt; - this.onCancel = options.onCancel; - } - - start(): void { - this.isListening = true; - this.listenForPrompt(); - } - - private listenForPrompt(): void { - if (!this.isListening) return; - - this.commands.promptQueue.pop() - .then((promptEvent) => { - this.activePrompt = promptEvent; - - // 如果有等待的输入,自动提交 - if (this.pendingInput) { - const input = this.pendingInput; - this.pendingInput = null; - const error = this.activePrompt.tryCommit(input); - if (error === null) { - this.activePrompt = null; - this.listenForPrompt(); - } else { - // 提交失败,把 prompt 交给 UI 显示错误 - this.onPrompt(promptEvent); - } - return; - } - - this.onPrompt(promptEvent); - }) - .catch((reason) => { - this.activePrompt = null; - this.onCancel(reason?.message || 'Cancelled'); - }); - } - - /** - * Submit an input string to the current prompt. - * @returns null on success (input accepted), error string on validation failure - */ - submit(input: string): string | null { - if (!this.activePrompt) { - // 没有活跃 prompt,保存为待处理输入 - this.pendingInput = input; - return null; - } - - const error = this.activePrompt.tryCommit(input); - if (error === null) { - this.activePrompt = null; - this.listenForPrompt(); - } - return error; - } - - cancel(reason?: string): void { - if (this.activePrompt) { - this.activePrompt.cancel(reason); - this.activePrompt = null; - } - this.onCancel(reason); - } - - stop(): void { - this.isListening = false; - if (this.activePrompt) { - this.activePrompt.cancel('Handler stopped'); - this.activePrompt = null; - } - } - - destroy(): void { - this.stop(); - } -} - -export function createPromptHandler( - options: PromptHandlerOptions, -): PromptHandler { - return new PromptHandler(options); -} diff --git a/packages/framework/src/input/index.ts b/packages/framework/src/input/index.ts deleted file mode 100644 index e7f0a6d..0000000 --- a/packages/framework/src/input/index.ts +++ /dev/null @@ -1,11 +0,0 @@ -export { - InputMapper, - createInputMapper, - type InputMapperOptions, -} from './InputMapper.js'; - -export { - PromptHandler, - createPromptHandler, - type PromptHandlerOptions, -} from './PromptHandler.js'; diff --git a/packages/framework/src/scenes/ReactiveScene.ts b/packages/framework/src/scenes/ReactiveScene.ts deleted file mode 100644 index 6e48046..0000000 --- a/packages/framework/src/scenes/ReactiveScene.ts +++ /dev/null @@ -1,53 +0,0 @@ -import Phaser from 'phaser'; -import { effect } from '@preact/signals-core'; -import type { MutableSignal, IGameContext, CommandResult } from 'boardgame-core'; - -type DisposeFn = () => void; - -export interface ReactiveSceneOptions> { - gameContext: IGameContext; -} - -export abstract class ReactiveScene> extends Phaser.Scene { - protected gameContext!: IGameContext; - protected state!: MutableSignal; - protected commands!: IGameContext['commands']; - private effects: DisposeFn[] = []; - - constructor(key: string) { - super(key); - } - - init(data: ReactiveSceneOptions): void { - this.gameContext = data.gameContext; - this.state = data.gameContext.state; - this.commands = data.gameContext.commands; - } - - protected watch(fn: () => void): DisposeFn { - const e = effect(fn); - this.effects.push(e); - return e; - } - - protected async runCommand(input: string): Promise> { - return this.commands.run(input); - } - - create(): void { - this.events.on('shutdown', this.cleanupEffects, this); - this.onStateReady(this.state.value); - this.setupBindings(); - } - - private cleanupEffects(): void { - for (const e of this.effects) { - e(); - } - this.effects = []; - } - - protected abstract onStateReady(state: TState): void; - - protected abstract setupBindings(): void; -} diff --git a/packages/framework/src/ui/CommandLog.tsx b/packages/framework/src/ui/CommandLog.tsx deleted file mode 100644 index 5b828d1..0000000 --- a/packages/framework/src/ui/CommandLog.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { h } from 'preact'; -import { Signal } from '@preact/signals-core'; - -interface CommandLogProps { - entries: Signal>; - maxEntries?: number; -} - -export function CommandLog({ entries, maxEntries = 50 }: CommandLogProps) { - const displayEntries = entries.value.slice(-maxEntries).reverse(); - - return ( -
- {displayEntries.length === 0 ? ( -
No commands yet
- ) : ( -
- {displayEntries.map((entry, i) => ( -
- - {new Date(entry.timestamp).toLocaleTimeString()} - - > {entry.input} - - {entry.result} - -
- ))} -
- )} -
- ); -} diff --git a/packages/framework/src/ui/PromptDialog.tsx b/packages/framework/src/ui/PromptDialog.tsx deleted file mode 100644 index 22587a1..0000000 --- a/packages/framework/src/ui/PromptDialog.tsx +++ /dev/null @@ -1,120 +0,0 @@ -import { h } from 'preact'; -import { useState, useCallback } from 'preact/hooks'; -import type { PromptEvent, CommandSchema, CommandParamSchema } from 'boardgame-core'; - -interface PromptDialogProps { - prompt: PromptEvent | null; - onSubmit: (input: string) => string | null | void; - onCancel: () => void; -} - -function schemaToPlaceholder(schema: CommandSchema): string { - const parts: string[] = [schema.name]; - for (const param of schema.params) { - if (param.required) { - parts.push(`<${param.name}>`); - } else { - parts.push(`[${param.name}]`); - } - } - return parts.join(' '); -} - -function schemaToFields(schema: CommandSchema): Array<{ param: CommandParamSchema; label: string }> { - return schema.params - .filter(p => p.required) - .map(p => ({ param: p, label: p.name })); -} - -export function PromptDialog({ prompt, onSubmit, onCancel }: PromptDialogProps) { - const [values, setValues] = useState>({}); - const [error, setError] = useState(null); - - const handleSubmit = useCallback(() => { - if (!prompt) return; - - const fieldValues = schemaToFields(prompt.schema).map(f => values[f.label] || ''); - const cmdString = [prompt.schema.name, ...fieldValues].join(' '); - - const err = onSubmit(cmdString); - if (err != null) { - setError(err); - } else { - setValues({}); - setError(null); - } - }, [prompt, values, onSubmit]); - - const handleCancel = useCallback(() => { - onCancel(); - setValues({}); - setError(null); - }, [onCancel]); - - if (!prompt) return null; - - const fields = schemaToFields(prompt.schema); - const placeholder = schemaToPlaceholder(prompt.schema); - - return ( -
-
-

{prompt.schema.name}

-

{placeholder}

- - {fields.length > 0 ? ( -
- {fields.map(({ param, label }, index) => ( -
- - setValues(prev => ({ ...prev, [label]: (e.target as HTMLInputElement).value }))} - onKeyDown={(e) => { if (e.key === 'Enter') handleSubmit(); }} - autoFocus={index === 0} - /> -
- ))} -
- ) : ( -
- { - const val = (e.target as HTMLInputElement).value; - setValues({ _raw: val }); - }} - onKeyDown={(e) => { if (e.key === 'Enter') handleSubmit(); }} - autoFocus - /> -
- )} - - {error && ( -

{error}

- )} - -
- - -
-
-
- ); -}