diff --git a/packages/chess/src/modifiers/schema.ts b/packages/chess/src/modifiers/schema.ts index 5ace534..8c8ad94 100644 --- a/packages/chess/src/modifiers/schema.ts +++ b/packages/chess/src/modifiers/schema.ts @@ -1,10 +1,14 @@ /** * Zod schemas for ModifierProfile serialization and validation. * - * The `value` field on TypeModifier and InstanceModifier is intentionally - * `z.unknown()` — per-kind value validation happens at descriptor - * registration time, not at profile-parse time. This keeps the schema - * forwards-compatible with future modifier kinds. + * The `value` field on TypeModifier and InstanceModifier uses `z.any()` + * rather than `z.unknown()` — Zod v4 infers `z.unknown()` object fields as + * optional (`value?: unknown`) which conflicts with the required `value: + * unknown` in the TypeModifier/InstanceModifier interfaces. `z.any()` infers + * as a required `any` field while still accepting all values. + * + * Per-kind value validation happens at descriptor registration time, not at + * profile-parse time. This keeps the schema forwards-compatible. */ import { z } from "zod"; import type { ModifierProfile } from "./types.js"; @@ -47,7 +51,7 @@ export const TypeModifierSchema = z.object({ kind: ModifierKindIdSchema, pieceType: PieceTypeSchema, color: PieceColorExtSchema, - value: z.unknown(), + value: z.any() as z.ZodType, }); // --------------------------------------------------------------------------- @@ -57,7 +61,7 @@ export const TypeModifierSchema = z.object({ export const InstanceModifierSchema = z.object({ kind: ModifierKindIdSchema, square: SquareStringSchema, - value: z.unknown(), + value: z.any() as z.ZodType, }); // --------------------------------------------------------------------------- diff --git a/packages/chess/src/ui/Lobby.tsx b/packages/chess/src/ui/Lobby.tsx index 09fb469..3be840c 100644 --- a/packages/chess/src/ui/Lobby.tsx +++ b/packages/chess/src/ui/Lobby.tsx @@ -15,6 +15,8 @@ import { import type { LayoutRequest } from '../net/types'; import { LayoutPicker } from './LayoutPicker'; import { LayoutEditor } from './LayoutEditor'; +import { parseModifierProfile } from '../modifiers/schema'; +import type { ModifierProfile } from '../modifiers/types'; interface LobbyProps { /** Optional — when provided, create/join/solo flows reset the local @@ -38,6 +40,10 @@ export function Lobby({ chessState }: LobbyProps = {}) { const [selectedLayout, setSelectedLayout] = useState(CLASSIC_LAYOUT); const [editorOpen, setEditorOpen] = useState(false); + // Modifier profile pre-selected from the ?modifierProfile URL param. + // Stored here for future integration with the modifier profile picker. + const [_preselectedProfile, setPreselectedProfile] = + useState(null); useEffect(() => { const layoutId = searchParams.get('layoutId'); @@ -69,6 +75,22 @@ export function Lobby({ chessState }: LobbyProps = {}) { } } } + + // Pre-select a modifier profile from the ?modifierProfile param. + // The profile is base64-encoded JSON. Silently ignore on parse + // failure — a bad link shouldn't break the lobby. + const modifierProfileParam = searchParams.get('modifierProfile'); + if (modifierProfileParam !== null) { + try { + const profile = parseModifierProfile( + JSON.parse(atob(modifierProfileParam)), + ); + setPreselectedProfile(profile); + } catch { + /* invalid param — ignore */ + } + } + // Only depends on searchParams: we pre-select from query params // on first mount or when the URL changes. selectedLayout is // intentionally omitted so user picks after mount don't re-trigger diff --git a/packages/chess/src/ui/PerTypePanel.tsx b/packages/chess/src/ui/PerTypePanel.tsx new file mode 100644 index 0000000..a16c0f7 --- /dev/null +++ b/packages/chess/src/ui/PerTypePanel.tsx @@ -0,0 +1,330 @@ +/** + * PerTypePanel — left panel of the Modifier Profile Editor. + * + * Lists existing per-type modifiers and provides an inline form to add new ones. + * Each modifier applies to ALL pieces of a given type+color combination. + * + * Value editing is uiForm-driven: + * "number" → plain number input (validated by Zod schema) + * "percentage" → 0–100 input, mapped internally to 0–1 + * "promotion-target" → select: queen / rook / bishop / knight / disabled + * "direction-set" → placeholder (T21+) + * "capture-flags" → placeholder (T21+) + */ +import { useState } from 'react'; +import { MODIFIER_REGISTRY } from '../modifiers/index.js'; +import type { TypeModifier, ModifierDescriptor } from '../modifiers/types.js'; +import type { PieceType, PieceColor } from '../schema.js'; + +const PIECE_TYPES: PieceType[] = [ + 'pawn', 'knight', 'bishop', 'rook', 'queen', 'king', +]; +const COLORS: (PieceColor | 'both')[] = ['white', 'black', 'both']; + +interface Props { + modifiers: readonly TypeModifier[]; + onAdd: (modifier: TypeModifier) => void; + onDelete: (index: number) => void; +} + +/** + * Parse the raw string input into the typed value expected by the + * descriptor's Zod schema. Returns `undefined` when the input is + * empty or unconvertible (blocks the Save button). + */ +function parseFormValue(descriptor: ModifierDescriptor, rawValue: string): unknown { + switch (descriptor.uiForm) { + case 'number': { + if (rawValue === '') return undefined; + const n = Number(rawValue); + return isNaN(n) ? undefined : n; + } + case 'percentage': { + if (rawValue === '') return undefined; + const n = Number(rawValue); + return isNaN(n) ? undefined : n / 100; + } + case 'promotion-target': + return rawValue || undefined; + case 'direction-set': + case 'capture-flags': + // Placeholder forms cannot produce a valid value yet. + return undefined; + } +} + +export function PerTypePanel({ modifiers, onAdd, onDelete }: Props) { + const [formOpen, setFormOpen] = useState(false); + const [pieceType, setPieceType] = useState('pawn'); + const [color, setColor] = useState('both'); + + const descriptors = MODIFIER_REGISTRY.list(); + const [kindId, setKindId] = useState(descriptors[0]?.id ?? ''); + const [rawValue, setRawValue] = useState(''); + + const descriptor = descriptors.find((d) => d.id === kindId); + const parsedValue = descriptor ? parseFormValue(descriptor, rawValue) : undefined; + const isValid = + descriptor !== undefined && + parsedValue !== undefined && + descriptor.valueSchema.safeParse(parsedValue).success; + + function resetForm() { + setPieceType('pawn'); + setColor('both'); + setKindId(descriptors[0]?.id ?? ''); + setRawValue(''); + } + + function handleSave() { + if (!isValid || !descriptor) return; + onAdd({ + kind: descriptor.id, + pieceType, + color, + value: parsedValue, + }); + setFormOpen(false); + resetForm(); + } + + return ( +
+ {/* Panel header */} +
+

+ Per-Type Modifiers +

+
+ + {/* Scrollable body */} +
+ {/* Empty state */} + {modifiers.length === 0 && !formOpen && ( +

No type modifiers yet.

+ )} + + {/* Existing modifier rows */} + {modifiers.map((mod, i) => { + const desc = MODIFIER_REGISTRY.get(mod.kind); + return ( +
+ + {mod.pieceType} + {' '} + ({mod.color}) + {': '} + + {desc ? desc.describe(mod.value) : String(mod.value)} + + + +
+ ); + })} + + {/* Add button — hidden while form is open */} + {!formOpen && ( + + )} + + {/* Inline add form */} + {formOpen && ( +
+ {/* Piece type */} +
+ + +
+ + {/* Color */} +
+ + +
+ + {/* Modifier kind */} +
+ + +
+ + {/* Value input — rendered by uiForm type */} + {descriptor !== undefined && ( +
+ + +
+ )} + + {/* Actions */} +
+ + +
+
+ )} +
+
+ ); +} + +// ── Value input subcomponent ──────────────────────────────────────────── + +function ValueInput({ + descriptor, + rawValue, + onChange, +}: { + descriptor: ModifierDescriptor; + rawValue: string; + onChange: (v: string) => void; +}) { + switch (descriptor.uiForm) { + case 'number': + return ( + onChange(e.target.value)} + placeholder="Enter a number" + className="w-full px-2 py-1.5 text-sm border border-neutral-300 rounded focus:outline-none focus:ring-2 focus:ring-neutral-900" + /> + ); + + case 'percentage': + return ( +
+ onChange(e.target.value)} + placeholder="0–100" + className="w-full px-2 py-1.5 text-sm border border-neutral-300 rounded focus:outline-none focus:ring-2 focus:ring-neutral-900" + /> + % +
+ ); + + case 'promotion-target': + return ( + + ); + + case 'direction-set': + return ( +

+ Direction set editor coming soon +

+ ); + + case 'capture-flags': + return ( +

+ Capture flags editor coming soon +

+ ); + } +}