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.
This commit is contained in:
parent
3bdfba38e0
commit
699c288a98
9 changed files with 602 additions and 12 deletions
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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++) {
|
||||
|
|
|
|||
|
|
@ -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"]),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ${
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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<StartingLayout | null>(null);
|
||||
const [clipboard, setClipboard] = useState<ModifierClipboard>({ 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({
|
|||
<button
|
||||
data-testid="save-profile"
|
||||
onClick={handleSaveToLibrary}
|
||||
className="px-3 py-1.5 text-sm font-semibold text-neutral-700 bg-neutral-100 rounded hover:bg-neutral-200 transition-colors"
|
||||
disabled={hasPresetErrors}
|
||||
title={hasPresetErrors ? presetLint.errors[0] : undefined}
|
||||
className="px-3 py-1.5 text-sm font-semibold text-neutral-700 bg-neutral-100 rounded hover:bg-neutral-200 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
|
|
@ -449,14 +475,83 @@ export function ModifierProfileEditor({
|
|||
/>
|
||||
</main>
|
||||
|
||||
{/* RIGHT — profile library (T23) */}
|
||||
<ProfileLibraryPanel
|
||||
key={libraryVersion}
|
||||
onLoad={handleLoadFromLibrary}
|
||||
onDelete={handleDeleteFromLibrary}
|
||||
onStarToggle={handleStarToggle}
|
||||
onDuplicate={handleDuplicate}
|
||||
/>
|
||||
{/* 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). */}
|
||||
<aside
|
||||
data-testid="right-column"
|
||||
className="w-72 border-l border-neutral-200 bg-neutral-50 flex flex-col overflow-hidden"
|
||||
>
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label="Right panel tabs"
|
||||
className="flex border-b border-neutral-200"
|
||||
>
|
||||
<button
|
||||
role="tab"
|
||||
data-testid="right-tab-library"
|
||||
aria-selected={rightTab === 'library'}
|
||||
onClick={() => setRightTab('library')}
|
||||
className={`flex-1 px-3 py-2 text-xs font-bold uppercase tracking-wider transition-colors ${
|
||||
rightTab === 'library'
|
||||
? 'bg-white text-neutral-900 border-b-2 border-blue-500 -mb-px'
|
||||
: 'text-neutral-400 hover:bg-neutral-100'
|
||||
}`}
|
||||
>
|
||||
Library
|
||||
</button>
|
||||
<button
|
||||
role="tab"
|
||||
data-testid="right-tab-presets"
|
||||
aria-selected={rightTab === 'presets'}
|
||||
onClick={() => setRightTab('presets')}
|
||||
className={`flex-1 px-3 py-2 text-xs font-bold uppercase tracking-wider transition-colors ${
|
||||
rightTab === 'presets'
|
||||
? 'bg-white text-neutral-900 border-b-2 border-blue-500 -mb-px'
|
||||
: 'text-neutral-400 hover:bg-neutral-100'
|
||||
}`}
|
||||
>
|
||||
Presets
|
||||
{(profile.presetActivations?.length ?? 0) > 0 && (
|
||||
<span className="ml-1.5 inline-flex items-center justify-center text-[10px] font-bold rounded-full bg-blue-100 text-blue-700 px-1.5 py-0.5">
|
||||
{profile.presetActivations?.length ?? 0}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{rightTab === 'library' ? (
|
||||
<ProfileLibraryPanel
|
||||
key={libraryVersion}
|
||||
onLoad={handleLoadFromLibrary}
|
||||
onDelete={handleDeleteFromLibrary}
|
||||
onStarToggle={handleStarToggle}
|
||||
onDuplicate={handleDuplicate}
|
||||
/>
|
||||
) : (
|
||||
<PresetPanel
|
||||
activations={profile.presetActivations ?? []}
|
||||
boundLayout={boundLayout}
|
||||
onChange={(next: readonly PresetActivation[]) =>
|
||||
pushSnapshot((p) =>
|
||||
next.length === 0
|
||||
? (() => {
|
||||
const {
|
||||
presetActivations: _drop,
|
||||
...rest
|
||||
} = p;
|
||||
return { ...rest };
|
||||
})()
|
||||
: { ...p, presetActivations: next },
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
<CustomModifierEditor
|
||||
|
|
@ -491,8 +586,8 @@ function ProfileLibraryPanel({
|
|||
});
|
||||
|
||||
return (
|
||||
<aside className="w-72 border-l border-neutral-200 bg-neutral-50 flex flex-col overflow-hidden">
|
||||
<div className="px-4 pt-4 pb-2 border-b border-neutral-200">
|
||||
<div className="h-full flex flex-col overflow-hidden">
|
||||
<div className="px-4 pt-3 pb-2 border-b border-neutral-200">
|
||||
<p className="text-xs font-bold text-neutral-400 uppercase tracking-widest">
|
||||
Profiles
|
||||
</p>
|
||||
|
|
@ -566,6 +661,6 @@ function ProfileLibraryPanel({
|
|||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
297
packages/chess/src/ui/PresetPanel.tsx
Normal file
297
packages/chess/src/ui/PresetPanel.tsx
Normal file
|
|
@ -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<string>();
|
||||
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<string, PresetActivation>();
|
||||
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 (
|
||||
<div
|
||||
data-testid="preset-panel"
|
||||
className="h-full flex flex-col overflow-hidden"
|
||||
>
|
||||
<div className="px-4 pt-3 pb-2 border-b border-neutral-200">
|
||||
<p className="text-xs font-bold text-neutral-400 uppercase tracking-widest">
|
||||
Bundled Presets
|
||||
</p>
|
||||
<p className="text-[11px] text-neutral-500 mt-1 leading-snug">
|
||||
Rule variants to auto-activate when this profile is picked.
|
||||
Unioned with the selected layout's suggestions; profile
|
||||
config wins on overlap.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Diagnostic banners */}
|
||||
{(errors.length > 0 || warnings.length > 0) && (
|
||||
<div className="px-3 pt-2 space-y-1.5">
|
||||
{errors.map((e, i) => (
|
||||
<div
|
||||
key={`err-${String(i)}`}
|
||||
data-testid={`preset-panel-error-${String(i)}`}
|
||||
className="px-2.5 py-1.5 text-xs text-red-800 bg-red-50 border border-red-200 rounded"
|
||||
>
|
||||
⚠ {e}
|
||||
</div>
|
||||
))}
|
||||
{warnings.map((w, i) => (
|
||||
<div
|
||||
key={`warn-${String(i)}`}
|
||||
data-testid={`preset-panel-warning-${String(i)}`}
|
||||
className="px-2.5 py-1.5 text-xs text-amber-800 bg-amber-50 border border-amber-200 rounded"
|
||||
>
|
||||
ℹ {w}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-3 space-y-2">
|
||||
{allPresets.map((preset) => {
|
||||
const active = activeById.get(preset.id);
|
||||
return (
|
||||
<div
|
||||
key={preset.id}
|
||||
data-testid={`preset-panel-row-${preset.id}`}
|
||||
className={`rounded-lg border px-2.5 py-2 transition-colors ${
|
||||
active !== undefined
|
||||
? 'border-blue-300 bg-blue-50/40'
|
||||
: 'border-neutral-200 bg-white'
|
||||
}`}
|
||||
>
|
||||
<label className="flex items-start gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
data-testid={`preset-panel-toggle-${preset.id}`}
|
||||
checked={active !== undefined}
|
||||
onChange={() => togglePreset(preset.id)}
|
||||
className="mt-0.5 h-4 w-4 cursor-pointer"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-semibold text-neutral-800 truncate">
|
||||
{preset.name}
|
||||
</div>
|
||||
<div className="text-[11px] text-neutral-500 leading-snug">
|
||||
{preset.description}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{active !== undefined && (
|
||||
<div className="mt-2 pl-6 flex flex-wrap items-center gap-2">
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label={`Scope for ${preset.name}`}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
{(['both', 'white', 'black'] as const).map((s) => {
|
||||
const selected = active.scope === s;
|
||||
return (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={selected}
|
||||
data-testid={`preset-panel-scope-${preset.id}-${s}`}
|
||||
onClick={() => updateScope(preset.id, s)}
|
||||
className={`px-1.5 py-0.5 text-[10px] font-semibold rounded border ${
|
||||
selected
|
||||
? 'bg-neutral-900 text-white border-neutral-900'
|
||||
: 'bg-white text-neutral-700 border-neutral-200 hover:bg-neutral-50'
|
||||
}`}
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<label className="text-[10px] font-semibold text-neutral-500 uppercase tracking-wide flex items-center gap-1">
|
||||
Turns
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
step={1}
|
||||
data-testid={`preset-panel-turns-${preset.id}`}
|
||||
value={
|
||||
active.turnsRemaining === null
|
||||
? ''
|
||||
: String(active.turnsRemaining)
|
||||
}
|
||||
placeholder="∞"
|
||||
onChange={(e) => {
|
||||
const raw = e.target.value;
|
||||
if (raw === '') {
|
||||
updateTurns(preset.id, null);
|
||||
return;
|
||||
}
|
||||
const n = Number.parseInt(raw, 10);
|
||||
if (Number.isFinite(n) && n >= 1) {
|
||||
updateTurns(preset.id, n);
|
||||
}
|
||||
}}
|
||||
className="w-14 px-1 py-0.5 text-[11px] border border-neutral-300 rounded focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -260,6 +260,19 @@ export type InstanceModifierWire = z.infer<typeof InstanceModifierSchema>;
|
|||
* 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"]),
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue