From 699c288a98c3b978c22dec76076858ccb2dd7895 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Tue, 21 Apr 2026 14:03:52 -0600 Subject: [PATCH] feat(modifiers): bundle preset activations on profiles + editor Presets tab Profiles can now carry a list of PresetActivation entries alongside their per-type / per-instance modifiers. When such a profile is picked in the Lobby, its bundled presets are unioned with any layout suggestedPresets (profile config wins on id-collision). Changes: - ModifierProfile.presetActivations added (optional readonly array). Zod schema + server wire schema mirror the field; drift guard picks up forgotten updates on either side. - ModifierProfileEditor grows a Presets tab in the right column sharing space with the Library tab. Each preset row carries a checkbox, scope radio (both/white/black), and a turns counter (blank = permanent). Inline diagnostics warn on redundant layout overlap and on loose-scope incompatibility; hard errors under overlapping scope disable Save. - Lobby merges profile.presetActivations into its active preset set on profile select, on editor close, and on URL deep-link. - LayoutPicker suggested-preset chips now expose aria-pressed so the active state is readable by assistive tech + e2e tests. Tests: library round-trip preserves presetActivations (unit); two new e2e scenarios (pre-seeded bundled profile auto-activates; full editor-author loop persists + reflects in the lobby). 1752 unit + 105 playwright all green. --- .../chess/e2e/profile-layout-binding.spec.ts | 95 ++++++ packages/chess/src/modifiers/library.test.ts | 22 ++ packages/chess/src/modifiers/schema.ts | 15 + packages/chess/src/modifiers/types.ts | 12 + packages/chess/src/ui/LayoutPicker.tsx | 7 + packages/chess/src/ui/Lobby.tsx | 33 ++ .../chess/src/ui/ModifierProfileEditor.tsx | 119 ++++++- packages/chess/src/ui/PresetPanel.tsx | 297 ++++++++++++++++++ packages/server/src/protocol.ts | 14 + 9 files changed, 602 insertions(+), 12 deletions(-) create mode 100644 packages/chess/src/ui/PresetPanel.tsx diff --git a/packages/chess/e2e/profile-layout-binding.spec.ts b/packages/chess/e2e/profile-layout-binding.spec.ts index 4722761..353c36c 100644 --- a/packages/chess/e2e/profile-layout-binding.spec.ts +++ b/packages/chess/e2e/profile-layout-binding.spec.ts @@ -158,6 +158,101 @@ test.describe('Modifier profile ⇄ layout binding', () => { ).toHaveCount(0); }); + test('profile with bundled presetActivations auto-activates them in the Lobby', async ({ + page, + }) => { + await freshLobby(page); + // Seed a profile bound to the pawns-only layout (which suggests + // first-promotion-wins) AND bundling that same preset. This lets + // us verify the chip flips to active purely from the profile + // selection — no user-tap on the suggested-preset chip involved. + await page.evaluate( + ({ key }) => { + const entry = { + id: 'lib-with-preset', + name: 'FPW Profile', + profile: { + id: 'lib-with-preset', + name: 'FPW Profile', + description: '', + layoutId: 'pawns-only', + perType: [], + perInstance: [], + presetActivations: [ + { + id: 'first-promotion-wins', + scope: 'both', + turnsRemaining: null, + }, + ], + version: 1, + source: 'custom', + }, + starred: false, + updatedAt: Date.now(), + }; + localStorage.setItem(key, JSON.stringify([entry])); + }, + { key: PROFILE_LIBRARY_KEY }, + ); + await page.reload(); + + await page.getByTestId('profile-picker').selectOption('lib-with-preset'); + + // Layout auto-snaps to pawns-only (layoutId binding, already + // covered elsewhere but confirmed here for precondition). + await expect(page.getByTestId('layout-picker')).toHaveValue('pawns-only'); + + // The layout's suggested-preset chip for first-promotion-wins + // should now be pressed because the profile's + // presetActivations entry populated the Lobby's `presets` state. + const chip = page.getByTestId( + 'layout-suggested-preset-first-promotion-wins', + ); + await expect(chip).toBeVisible(); + await expect(chip).toHaveAttribute('aria-pressed', 'true'); + }); + + test('editor bundles a preset on save, which auto-activates on reselect', async ({ + page, + }) => { + await freshLobby(page); + + // Open the editor via the picker's "Custom…" option. + await page.getByTestId('profile-picker').selectOption('custom'); + await expect(page.getByTestId('modifier-editor-modal')).toBeVisible({ + timeout: 3000, + }); + + // Set name, bind the pawns-only layout, switch to the Presets tab. + await page.getByTestId('profile-name').fill('Bundled Presets Test'); + await page.getByTestId('bound-layout-picker').selectOption('pawns-only'); + await page.getByTestId('right-tab-presets').click(); + await expect(page.getByTestId('preset-panel')).toBeVisible(); + + // Toggle the first-promotion-wins preset on. + await page + .getByTestId('preset-panel-toggle-first-promotion-wins') + .check(); + // Scope defaults to "both" (the toggle button exposes aria-checked). + await expect( + page.getByTestId('preset-panel-scope-first-promotion-wins-both'), + ).toHaveAttribute('aria-checked', 'true'); + + // Save + close. + await page.getByTestId('save-profile').click(); + await page.keyboard.press('Escape'); + + // Back in the lobby: the suggested-preset chip for the bundled + // rule shows as pressed because the editor-close handler applied + // the profile's bundled presets to the Lobby's active set. + const chip = page.getByTestId( + 'layout-suggested-preset-first-promotion-wins', + ); + await expect(chip).toBeVisible(); + await expect(chip).toHaveAttribute('aria-pressed', 'true'); + }); + test('editor persists the bound layout on save', async ({ page }) => { await freshLobby(page); diff --git a/packages/chess/src/modifiers/library.test.ts b/packages/chess/src/modifiers/library.test.ts index ebc66fd..13fe389 100644 --- a/packages/chess/src/modifiers/library.test.ts +++ b/packages/chess/src/modifiers/library.test.ts @@ -151,6 +151,28 @@ describe("saveToLibrary()", () => { expect(reloaded?.profile.layoutId).toBe("classic"); }); + it("preserves presetActivations through the save/load round-trip", () => { + const bundledProfile: ModifierProfile = { + ...emptyProfile, + id: "bundled-profile", + presetActivations: [ + { id: "king-of-the-hill", scope: "both", turnsRemaining: null }, + { id: "piece-hp", scope: "white", turnsRemaining: 5 }, + ], + }; + saveToLibrary( + seed({ + id: "lib-bundled", + profile: bundledProfile, + }), + ); + const [reloaded] = loadLibrary(); + expect(reloaded?.profile.presetActivations).toEqual([ + { id: "king-of-the-hill", scope: "both", turnsRemaining: null }, + { id: "piece-hp", scope: "white", turnsRemaining: 5 }, + ]); + }); + it("evicts the oldest non-starred when MAX_ENTRIES is hit", () => { // Seed with MAX_ENTRIES entries, incrementing updatedAt. for (let i = 0; i < __test__.MAX_ENTRIES; i++) { diff --git a/packages/chess/src/modifiers/schema.ts b/packages/chess/src/modifiers/schema.ts index b0b8979..56da8da 100644 --- a/packages/chess/src/modifiers/schema.ts +++ b/packages/chess/src/modifiers/schema.ts @@ -73,6 +73,20 @@ export const InstanceModifierSchema = z.object({ // ModifierProfile // --------------------------------------------------------------------------- +/** + * Preset activation bundled on a profile. Matches the runtime + * `PresetActivation` shape in presets/active-set.ts. We don't + * enum-check `id` against the registry here — the Lobby + server + * already tolerate unknown ids (skip + warn), and a strict enum + * would silently drop profiles that reference a preset not yet + * registered on the receiver (e.g. during a rolling schema bump). + */ +const PresetActivationSchema = z.object({ + id: z.string().min(1), + scope: z.enum(["both", "white", "black"]), + turnsRemaining: z.number().int().positive().nullable(), +}); + export const ModifierProfileSchema = z.object({ id: z.string().min(1), name: z.string().min(1), @@ -80,6 +94,7 @@ export const ModifierProfileSchema = z.object({ layoutId: z.string().optional(), perType: z.array(TypeModifierSchema), perInstance: z.array(InstanceModifierSchema), + presetActivations: z.array(PresetActivationSchema).optional(), version: z.literal(1), source: z.enum(["premade", "custom"]), }); diff --git a/packages/chess/src/modifiers/types.ts b/packages/chess/src/modifiers/types.ts index 6a4005f..931f4b2 100644 --- a/packages/chess/src/modifiers/types.ts +++ b/packages/chess/src/modifiers/types.ts @@ -14,6 +14,7 @@ */ import type { EntityId, Session } from "@paratype/rete"; import type { PieceType, PieceColor, ChessAttrKey } from "../schema.js"; +import type { PresetActivation } from "../presets/active-set.js"; import type { ZodType } from "zod"; /** The six T1 modifier category identifiers. */ @@ -84,6 +85,17 @@ export interface ModifierProfile { readonly layoutId?: string | undefined; readonly perType: readonly TypeModifier[]; readonly perInstance: readonly InstanceModifier[]; + /** + * Optional preset activations bundled with this profile. When the + * profile is picked in the Lobby, these are unioned with the + * layout's `suggestedPresets` (same-id entries favour the profile's + * full config — scope + turnsRemaining — over the layout's default + * `{scope: 'both', turnsRemaining: null}`). Validated at authoring + * time against ActivePresetSet so incompatible pairs can't be + * saved. Omit or leave empty to author a layout/preset-agnostic + * profile — the former behaviour. + */ + readonly presetActivations?: readonly PresetActivation[]; readonly version: 1; readonly source: "premade" | "custom"; } diff --git a/packages/chess/src/ui/LayoutPicker.tsx b/packages/chess/src/ui/LayoutPicker.tsx index 1553613..fe2a41c 100644 --- a/packages/chess/src/ui/LayoutPicker.tsx +++ b/packages/chess/src/ui/LayoutPicker.tsx @@ -154,6 +154,13 @@ export function LayoutPicker({ key={presetId} data-testid={`layout-suggested-preset-${presetId}`} type="button" + // aria-pressed exposes the toggle state to both + // accessibility tooling and e2e tests. Matches the + // WAI-ARIA toggle-button pattern — isActive reads + // from the lobby-level `activations` array so it + // also reflects bundles coming from the selected + // modifier profile, not just user taps here. + aria-pressed={isActive} onClick={() => togglePreset(presetId)} disabled={disabled || !setPresets} className={`inline-flex px-2 py-0.5 rounded-full text-xs transition-colors border ${ diff --git a/packages/chess/src/ui/Lobby.tsx b/packages/chess/src/ui/Lobby.tsx index a71f61a..f0bbbdc 100644 --- a/packages/chess/src/ui/Lobby.tsx +++ b/packages/chess/src/ui/Lobby.tsx @@ -170,6 +170,18 @@ export function Lobby({ chessState }: LobbyProps = {}) { setSelectedLayout(layout); } } + // Hydrate bundled presets — same union+dedupe contract as + // the picker's handleProfileChange so URL and in-lobby + // selection behave identically. + const urlBundled = profile.presetActivations; + if (urlBundled !== undefined && urlBundled.length > 0) { + setPresets((prev) => { + const next = prev.filter( + (p) => !urlBundled.some((b) => b.id === p.id), + ); + return [...next, ...urlBundled]; + }); + } } catch { /* invalid param — ignore */ } @@ -231,6 +243,19 @@ export function Lobby({ chessState }: LobbyProps = {}) { setSelectedLayout(layout); } } + // Union+dedupe the profile's bundled presets into the lobby's + // active preset set. Profile's explicit config (scope + + // turnsRemaining) wins when the same id already exists — + // authored intent > previously-selected defaults. Non-bundled + // entries stay intact so user toggles from a layout's + // suggestedPresets survive switching profiles. + const bundled = entry.profile.presetActivations; + if (bundled !== undefined && bundled.length > 0) { + setPresets((prev) => { + const next = prev.filter((p) => !bundled.some((b) => b.id === p.id)); + return [...next, ...bundled]; + }); + } } /** @@ -256,6 +281,14 @@ export function Lobby({ chessState }: LobbyProps = {}) { setSelectedLayout(layout); } } + // Same union+dedupe as handleProfileChange for bundled presets. + const bundled = newest.profile.presetActivations; + if (bundled !== undefined && bundled.length > 0) { + setPresets((prev) => { + const next = prev.filter((p) => !bundled.some((b) => b.id === p.id)); + return [...next, ...bundled]; + }); + } } /** diff --git a/packages/chess/src/ui/ModifierProfileEditor.tsx b/packages/chess/src/ui/ModifierProfileEditor.tsx index ed718dd..4f67c4d 100644 --- a/packages/chess/src/ui/ModifierProfileEditor.tsx +++ b/packages/chess/src/ui/ModifierProfileEditor.tsx @@ -25,6 +25,8 @@ import { CustomModifierEditor } from './CustomModifierEditor.js'; import { PerInstancePanel } from './PerInstancePanel'; import { PerTypePanel } from './PerTypePanel.js'; import { ConflictResolutionPanel } from './ConflictResolutionPanel.js'; +import { PresetPanel, lintPresetActivations } from './PresetPanel.js'; +import type { PresetActivation } from '@paratype/chess'; export type ModifierClipboard = | { kind: 'empty' } @@ -106,6 +108,14 @@ export function ModifierProfileEditor({ const [boundLayout, setBoundLayout] = useState(null); const [clipboard, setClipboard] = useState({ kind: 'empty' }); const layouts = useMemo(() => LAYOUT_REGISTRY.list(), []); + /** + * Which subpanel is visible in the right column. Presets and the + * Library share real estate so the main area keeps enough width + * for the per-instance board. Defaults to Library so the existing + * open → load flow is unchanged; users who want to bundle presets + * switch once and the editor remembers until the modal closes. + */ + const [rightTab, setRightTab] = useState<'library' | 'presets'>('library'); // Fresh profile whenever the modal opens. useEffect(() => { @@ -168,9 +178,23 @@ export function ModifierProfileEditor({ if (!isOpen) return null; + // Lint the currently-authored preset bundle. Hard errors disable + // Save; soft warnings are surfaced in the PresetPanel itself. + const presetLint = lintPresetActivations( + profile.presetActivations ?? [], + boundLayout, + ); + const hasPresetErrors = presetLint.errors.length > 0; + // ── Library actions ───────────────────────────────────────────────── function handleSaveToLibrary() { + if (hasPresetErrors) { + toast.error( + `Fix preset conflicts before saving: ${presetLint.errors[0]}`, + ); + return; + } const entry: SavedModifierProfile = { id: profile.id, name: profile.name.trim() || 'Untitled Profile', @@ -335,7 +359,9 @@ export function ModifierProfileEditor({ @@ -449,14 +475,83 @@ export function ModifierProfileEditor({ /> - {/* RIGHT — profile library (T23) */} - + {/* RIGHT COLUMN — tabbed between Presets (bundle rule + variants on this profile) and Library (load/save). The + two subpanels share real estate so fitting four full- + width aside columns doesn't squeeze Per-Instance below + its minimum comfortable width (need room for the 8×8 + board preview). */} + -
+
+

Profiles

@@ -566,6 +661,6 @@ function ProfileLibraryPanel({ )}
- +
); } diff --git a/packages/chess/src/ui/PresetPanel.tsx b/packages/chess/src/ui/PresetPanel.tsx new file mode 100644 index 0000000..a3f3291 --- /dev/null +++ b/packages/chess/src/ui/PresetPanel.tsx @@ -0,0 +1,297 @@ +/** + * PresetPanel — Modifier Profile Editor's "Presets" sub-panel. + * + * Lets the user bundle a list of `PresetActivation` entries on the + * profile so that picking the profile in the Lobby auto-activates + * the bundled presets (unioned with any layout `suggestedPresets`, + * profile config winning on id-collision). + * + * UI structure: + * - One row per registered preset (from PRESET_REGISTRY). + * - Checkbox on the left toggles activation. + * - When activated, scope radio (both/white/black) + turns input + * (number, 0 = permanent rendered as blank) appear inline. + * - Warning banners at the top when: + * - Profile is bound to a layout AND that layout already + * suggests a preset the profile bundles. (Harmless but + * redundant — info tone.) + * - Two activated presets target disjoint colors (white+black) + * yet are declared incompatible. (Useful callout — still + * allowed by ActivePresetSet's loose compatibility rule, but + * usually a sign the author didn't mean it.) + * - Hard-error banner when two bundled presets are incompatible + * under overlapping scope — drives validate() to return false so + * the Save button disables upstream. + * + * The panel treats `PresetActivation` as the canonical unit: + * checking a preset inserts `{id, scope:'both', turnsRemaining:null}`; + * toggling scope / turns mutates that entry in place. + */ +import { useMemo } from 'react'; +import { PRESET_REGISTRY } from '@paratype/chess'; +import type { + PresetActivation, + PresetScope, + StartingLayout, +} from '@paratype/chess'; + +export interface PresetPanelProps { + readonly activations: readonly PresetActivation[]; + readonly onChange: (next: readonly PresetActivation[]) => void; + /** + * Profile's currently-bound layout (if any). Drives the + * "already suggested by this layout" redundancy notice. + */ + readonly boundLayout: StartingLayout | null; +} + +/** + * Compute a list of lint-style warnings + hard errors for the + * current activation set. `hasError: true` is consumed upstream to + * disable the Save button. + */ +export function lintPresetActivations( + activations: readonly PresetActivation[], + boundLayout: StartingLayout | null, +): { + readonly errors: readonly string[]; + readonly warnings: readonly string[]; +} { + const errors: string[] = []; + const warnings: string[] = []; + + // Hard error: two bundled presets are incompatible AND their + // scopes overlap. Mirrors ActivePresetSet's runtime check — + // catching it at authoring time stops bad profiles from entering + // the library. We check every unordered pair once. + const seenPair = new Set(); + for (const a of activations) { + const defA = PRESET_REGISTRY.get(a.id); + if (defA === undefined) continue; + for (const b of activations) { + if (a.id === b.id) continue; + const pairKey = [a.id, b.id].sort().join('|'); + if (seenPair.has(pairKey)) continue; + seenPair.add(pairKey); + if (!defA.incompatibleWith.includes(b.id)) continue; + const overlaps = scopesOverlap(a.scope, b.scope); + if (overlaps) { + errors.push( + `"${defA.name}" (${a.scope}) and "${PRESET_REGISTRY.get(b.id)?.name ?? b.id}" (${b.scope}) are incompatible under overlapping scope.`, + ); + } else { + // Loose-compatible pair — allowed but almost always a mistake. + warnings.push( + `"${defA.name}" (${a.scope}) and "${PRESET_REGISTRY.get(b.id)?.name ?? b.id}" (${b.scope}) are declared incompatible but their scopes don't overlap. Allowed, but double-check this is intentional.`, + ); + } + } + } + + // Layout redundancy notice — when the bound layout already + // suggests a preset the profile bundles. Same-id duplicates are + // harmless (Lobby's union+dedupe favours the profile's config), + // but surfacing the overlap helps authors keep profiles lean. + if (boundLayout?.suggestedPresets !== undefined) { + for (const act of activations) { + if (boundLayout.suggestedPresets.includes(act.id)) { + const name = PRESET_REGISTRY.get(act.id)?.name ?? act.id; + warnings.push( + `"${name}" is already suggested by layout "${boundLayout.name}". Keeping it on the profile is fine — just redundant.`, + ); + } + } + } + + return { errors, warnings }; +} + +/** + * Internal helper mirroring ActivePresetSet's private scopesOverlap. + * Duplicated rather than imported to keep the UI module from pulling + * in the engine-side active-set state machine. + */ +function scopesOverlap(a: PresetScope, b: PresetScope): boolean { + if (a === 'both' || b === 'both') return true; + return a === b; +} + +export function PresetPanel({ + activations, + onChange, + boundLayout, +}: PresetPanelProps) { + const allPresets = useMemo(() => PRESET_REGISTRY.getAll(), []); + const activeById = useMemo(() => { + const m = new Map(); + for (const a of activations) m.set(a.id, a); + return m; + }, [activations]); + + const { errors, warnings } = useMemo( + () => lintPresetActivations(activations, boundLayout), + [activations, boundLayout], + ); + + /** Toggle a preset on/off in the activation list. */ + const togglePreset = (id: string): void => { + const existing = activeById.get(id); + if (existing !== undefined) { + onChange(activations.filter((a) => a.id !== id)); + return; + } + onChange([ + ...activations, + { id, scope: 'both', turnsRemaining: null }, + ]); + }; + + const updateScope = (id: string, scope: PresetScope): void => { + onChange( + activations.map((a) => (a.id === id ? { ...a, scope } : a)), + ); + }; + + const updateTurns = (id: string, turns: number | null): void => { + onChange( + activations.map((a) => + a.id === id ? { ...a, turnsRemaining: turns } : a, + ), + ); + }; + + return ( +
+
+

+ Bundled Presets +

+

+ Rule variants to auto-activate when this profile is picked. + Unioned with the selected layout's suggestions; profile + config wins on overlap. +

+
+ + {/* Diagnostic banners */} + {(errors.length > 0 || warnings.length > 0) && ( +
+ {errors.map((e, i) => ( +
+ ⚠ {e} +
+ ))} + {warnings.map((w, i) => ( +
+ ℹ {w} +
+ ))} +
+ )} + +
+ {allPresets.map((preset) => { + const active = activeById.get(preset.id); + return ( +
+ + + {active !== undefined && ( +
+
+ {(['both', 'white', 'black'] as const).map((s) => { + const selected = active.scope === s; + return ( + + ); + })} +
+ +
+ )} +
+ ); + })} +
+
+ ); +} diff --git a/packages/server/src/protocol.ts b/packages/server/src/protocol.ts index 80876cb..8d05ca7 100644 --- a/packages/server/src/protocol.ts +++ b/packages/server/src/protocol.ts @@ -260,6 +260,19 @@ export type InstanceModifierWire = z.infer; * drift without requiring us to export the chess-side zod schema across * a zod major-version boundary. */ +/** + * Wire schema for a bundled preset activation. Shape-mirrored with + * chess-side `PresetActivation` in presets/active-set.ts. We don't + * enum-check `id` against the registry here — the engine tolerates + * unknown ids and the drift guard below only checks key parity with + * the chess-side type. + */ +export const PresetActivationWireSchema = z.object({ + id: z.string().min(1), + scope: z.enum(["both", "white", "black"]), + turnsRemaining: z.number().int().positive().nullable(), +}); + export const ModifierProfileSchema = z.object({ id: z.string().min(1), name: z.string().min(1), @@ -267,6 +280,7 @@ export const ModifierProfileSchema = z.object({ layoutId: z.string().optional(), perType: z.array(TypeModifierSchema), perInstance: z.array(InstanceModifierSchema), + presetActivations: z.array(PresetActivationWireSchema).optional(), version: z.literal(1), source: z.enum(["premade", "custom"]), });