diff --git a/packages/chess/src/modifiers/types.ts b/packages/chess/src/modifiers/types.ts index d6a0cdf..6a4005f 100644 --- a/packages/chess/src/modifiers/types.ts +++ b/packages/chess/src/modifiers/types.ts @@ -23,7 +23,8 @@ export type ModifierKindId = | "direction-additions" | "capture-flags" | "promotion-override" - | "damage-resistance"; + | "damage-resistance" + | (string & {}); /** * Named movement directions (from the perspective of the moving piece, diff --git a/packages/chess/src/ui/CustomModifierEditor.tsx b/packages/chess/src/ui/CustomModifierEditor.tsx new file mode 100644 index 0000000..6d5c25b --- /dev/null +++ b/packages/chess/src/ui/CustomModifierEditor.tsx @@ -0,0 +1,552 @@ +import { useState, useMemo } from 'react'; +import { toast } from 'sonner'; +import { type ZodType, z } from 'zod'; +import { PRIMITIVE_REGISTRY } from '../modifiers/primitives/index.js'; +import type { EffectPrimitiveNode, PrimitiveKind, EffectPrimitive } from '../modifiers/primitives/types.js'; +import type { CustomModifierDescriptor } from '../modifiers/custom/types.js'; +import { + saveToCustomModifierLibrary, + loadCustomModifierLibrary, + type SavedCustomModifier, +} from '../modifiers/custom/library.js'; +import { validateCustomDescriptor } from '../modifiers/custom/validate.js'; +import { asCustomModifierId } from '../modifiers/custom/types.js'; + +interface Props { + isOpen: boolean; + onClose: () => void; +} + +const CATEGORIES: Record = { + State: [ + 'seed-attribute', + 'add-to-attribute', + 'multiply-attribute', + 'add-direction', + 'set-capture-flag', + ], + Mechanic: [ + 'absorb-damage-with-attribute', + 'reflect-damage', + 'modify-movement-range', + 'block-move-type', + 'override-promotion', + ], + Advanced: [ + 'add-aura', + 'on-turn-start', + 'on-capture', + 'on-damaged', + 'conditional', + ], +}; + +function generateId(): string { + return `custom-${Math.random().toString(36).substring(2, 9)}`; +} + +function makeBlankDescriptor(): CustomModifierDescriptor { + return { + type: 'data', + id: asCustomModifierId(generateId()), + name: 'New Custom Modifier', + description: '', + version: 1, + primitives: [], + targetAttrs: [], // Will be auto-computed based on primitives + uiForm: 'primitive-composer', + source: 'custom', + }; +} + +/** Fallback to default params based on primitive schema */ +function generateDefaultParams(schema: ZodType): unknown { + try { + if (schema instanceof z.ZodDefault) { + return (schema as unknown as { _def: { defaultValue: () => unknown } })._def.defaultValue(); + } + if (schema instanceof z.ZodObject) { + const shape = schema.shape as Record>; + const result: Record = {}; + for (const [key, subSchema] of Object.entries(shape)) { + if (subSchema instanceof z.ZodDefault) { + result[key] = (subSchema as unknown as { _def: { defaultValue: () => unknown } })._def.defaultValue(); + } else if (subSchema instanceof z.ZodNumber) { + result[key] = 0; + } else if (subSchema instanceof z.ZodString) { + result[key] = ''; + } else if (subSchema instanceof z.ZodBoolean) { + result[key] = false; + } else if (subSchema instanceof z.ZodEnum) { + result[key] = (subSchema as unknown as { _def: { values: unknown[] } })._def.values[0]; + } else if (subSchema instanceof z.ZodArray) { + result[key] = []; + } else if (subSchema instanceof z.ZodOptional || subSchema instanceof z.ZodNullable) { + // Leave it undefined + } + } + return result; + } + if (schema instanceof z.ZodNumber) return 0; + if (schema instanceof z.ZodString) return ''; + if (schema instanceof z.ZodBoolean) return false; + if (schema instanceof z.ZodEnum) return (schema as unknown as { _def: { values: unknown[] } })._def.values[0]; + if (schema instanceof z.ZodArray) return []; + } catch (e) { + console.error('Failed to generate default params', e); + } + return {}; // Fallback +} + +export function CustomModifierEditor({ isOpen, onClose }: Props) { + const [descriptor, setDescriptor] = useState(makeBlankDescriptor()); + const [selectedIndex, setSelectedIndex] = useState(null); + + // Library state for the "Load" dialog + const [showLibrary, setShowLibrary] = useState(false); + const [libraryItems, setLibraryItems] = useState([]); + + // Open library + const openLibrary = () => { + setLibraryItems(loadCustomModifierLibrary()); + setShowLibrary(true); + }; + + const validationResult = useMemo(() => validateCustomDescriptor(descriptor), [descriptor]); + + const updateDescriptor = (updater: (prev: CustomModifierDescriptor) => CustomModifierDescriptor) => { + setDescriptor((prev) => updater(prev)); + }; + + const handleSave = () => { + const res = saveToCustomModifierLibrary(descriptor); + if (res.ok) { + toast.success('Custom modifier saved to library'); + } else { + toast.error(`Save failed: ${res.reason}`); + } + }; + + if (!isOpen) return null; + + return ( +
+
+ + {/* Header */} +
+
+

+ Primitive Composer +

+
+ updateDescriptor((p) => ({ ...p, name: e.target.value }))} + placeholder="Modifier Name" + className="px-3 py-1 text-sm font-semibold border border-neutral-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500" + maxLength={40} + /> + updateDescriptor((p) => ({ ...p, description: e.target.value }))} + placeholder="Description (optional)" + className="px-3 py-1 text-xs border border-neutral-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500" + maxLength={200} + /> +
+
+ ID: {descriptor.id} +
+
+
+ + +
+ +
+
+ + {/* Main Body (3 Columns) */} +
+ + {/* Left: Palette */} + + + {/* Center: Tree View */} +
+

+ Effect Sequence ({descriptor.primitives.length}) +

+ {descriptor.primitives.length === 0 ? ( +
+ Add primitives from the palette to build the modifier. +
+ ) : ( +
+ {descriptor.primitives.map((node, i) => { + const isSelected = selectedIndex === i; + const primitive = PRIMITIVE_REGISTRY.get(node.kind); + return ( +
setSelectedIndex(i)} + className={` + flex items-center justify-between p-3 rounded-lg border cursor-pointer transition-colors + ${isSelected + ? 'border-blue-500 bg-blue-50 shadow-sm ring-1 ring-blue-500/20' + : 'border-neutral-200 hover:border-neutral-300 bg-white hover:bg-neutral-50'} + `} + > +
+
+ {i + 1} +
+
+
+ {primitive?.label ?? node.kind} +
+
+ {JSON.stringify(node.params).substring(0, 60)} + {JSON.stringify(node.params).length > 60 ? '...' : ''} +
+
+
+ +
+ ); + })} +
+ )} +
+ + {/* Right: Inspector */} + +
+ + {/* Footer: Validation Status */} +
+
+ {validationResult.ok ? ( +
+ Valid Custom Descriptor +
+ ) : ( +
+ {validationResult.errors.length} Error{validationResult.errors.length !== 1 ? 's' : ''} +
+ )} +
+ {!validationResult.ok && ( +
+ {validationResult.errors.map(e => `[${e.path.join('.')}] ${e.message}`).join(' | ')} +
+ )} +
+
+ + {/* Load Modal Overlay */} + {showLibrary && ( +
+
+
+

Load from Library

+ +
+
+ {libraryItems.length === 0 ? ( +
No custom modifiers saved yet.
+ ) : ( +
+ {libraryItems.map(item => ( +
{ + setDescriptor(item.descriptor); + setSelectedIndex(null); + setShowLibrary(false); + }} + > +
+
{item.descriptor.name}
+
{item.descriptor.primitives.length} primitives
+
+
Load
+
+ ))} +
+ )} +
+
+
+ )} +
+ ); +} + +/** + * Sub-component for rendering the parameter form based on Zod schema introspection. + * Since fully parsing arbitrary Zod schemas into UI is complex, we use a hybrid approach: + * basic types get inputs, complex types get a JSON textarea fallback. + */ +function PrimitiveInspector({ + node, + primitive, + onChange +}: { + node: EffectPrimitiveNode; + primitive?: EffectPrimitive; + onChange: (params: unknown) => void; +}) { + if (!primitive) { + return
Unknown primitive kind: {node.kind}
; + } + + // Generic fallback for complex schemas (nested arrays, etc) + const isComplex = primitive.paramsSchema instanceof z.ZodObject === false; + + if (isComplex) { + const isArray = primitive.paramsSchema instanceof z.ZodArray; + return ( +
+ +