feat(ui): group RulesDrawer presets by category + layout suggested-rules chips
Phase F.3 + F.2-UI of the rule-variants epic.
Preset metadata:
- Added optional `category: 'king' | 'objective' | 'movement' |
'multi-move' | 'pieces' | 'misc'` on PresetDef (registry.ts).
Defaults to 'misc' when omitted.
- Categorized all 28 registered presets:
* King Variants: knightmate-rules, coregal, dual-king,
weak-dual-king.
* Objectives: capture-to-win, last-piece-standing,
first-promotion-wins, suicide-chess, capture-all,
extinction-chess.
* Movement: pawns-move-backward, double-pawn-sprint,
pawn-diagonal-no-capture, knights-leap-twice,
bishops-ignore-color, rook-warp, wrap-board,
berolina-pawns(+2), bouncing-pieces(+2), queen-splits.
* Multi-move: double-move, monster-rules.
* Pieces: piece-hp, king-heals, explosive-rook,
knight-immunity, poisoned-squares.
UI:
- RulesDrawer.tsx renders presets in category sections with
sentence-case headers ('King Variants', 'Objectives',
'Movement', 'Multi-move', 'Pieces', 'Misc'). Empty sections
hidden. Each section has data-testid='rules-category-<id>' for
e2e addressability. Existing search / scope / duration controls
preserved.
- LayoutPicker.tsx renders a 'Suggested rules' chip group under
each layout with suggestedPresets.length > 0. Chips toggle
presets on/off. Active = filled (neutral-800), inactive =
ghost (neutral-100). data-testid='layout-suggested-preset-<id>'
on each chip.
- Lobby.tsx wires activations + setPresets props to LayoutPicker
so chip toggles flow into the host-game payload
(rulesetIds array).
Tests: 1651 passing (unchanged — pure UI metadata).
This commit is contained in:
parent
50f7b37e12
commit
bd20032767
33 changed files with 361 additions and 257 deletions
|
|
@ -94,6 +94,7 @@ function emitMove(
|
|||
}
|
||||
|
||||
PRESET_REGISTRY.register({
|
||||
category: "movement",
|
||||
id: PRESET_ID,
|
||||
name: "Berolina Pawns (Extended)",
|
||||
description:
|
||||
|
|
|
|||
|
|
@ -134,6 +134,7 @@ function emitMove(
|
|||
}
|
||||
|
||||
PRESET_REGISTRY.register({
|
||||
category: "movement",
|
||||
id: PRESET_ID,
|
||||
name: "Berolina Pawns",
|
||||
description:
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ const ORTHOGONAL_DELTAS: ReadonlyArray<readonly [number, number]> = [
|
|||
];
|
||||
|
||||
PRESET_REGISTRY.register({
|
||||
category: "movement",
|
||||
id: "bishops-ignore-color",
|
||||
name: "Colour-Blind Bishops",
|
||||
description: "Bishops may additionally step one square orthogonally to an empty or enemy-occupied square.",
|
||||
|
|
|
|||
|
|
@ -192,6 +192,7 @@ function walkBouncingRay(
|
|||
}
|
||||
|
||||
PRESET_REGISTRY.register({
|
||||
category: "movement",
|
||||
id: "bouncing-pieces-2",
|
||||
name: "Bouncing Pieces (Extended)",
|
||||
description:
|
||||
|
|
|
|||
|
|
@ -203,6 +203,7 @@ function walkBouncingRay(
|
|||
}
|
||||
|
||||
PRESET_REGISTRY.register({
|
||||
category: "movement",
|
||||
id: "bouncing-pieces",
|
||||
name: "Bouncing Pieces",
|
||||
description:
|
||||
|
|
|
|||
|
|
@ -94,6 +94,7 @@ import { PRESET_REGISTRY } from "./registry.js";
|
|||
import type { GameResult } from "../engine.js";
|
||||
|
||||
PRESET_REGISTRY.register({
|
||||
category: "objective",
|
||||
id: "capture-all",
|
||||
name: "Capture All",
|
||||
description: "Capture every enemy piece to win. Kings are not royal.",
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ interface CaptureToWinState extends Record<string, unknown> {
|
|||
}
|
||||
|
||||
PRESET_REGISTRY.register({
|
||||
category: "objective",
|
||||
id: "capture-to-win",
|
||||
name: "First Blood",
|
||||
description:
|
||||
|
|
|
|||
|
|
@ -64,6 +64,7 @@ import { PRESET_REGISTRY } from "./registry.js";
|
|||
import type { EntityId } from "@paratype/rete";
|
||||
|
||||
PRESET_REGISTRY.register({
|
||||
category: "king",
|
||||
id: "coregal",
|
||||
name: "Coregal",
|
||||
description:
|
||||
|
|
|
|||
|
|
@ -75,6 +75,7 @@
|
|||
import { PRESET_REGISTRY } from "./registry.js";
|
||||
|
||||
PRESET_REGISTRY.register({
|
||||
category: "multi-move",
|
||||
id: "double-move",
|
||||
name: "Double Move",
|
||||
description:
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ function getDoubleSprintMove(engine: ChessEngine, pieceId: EntityId): LegalMove[
|
|||
}
|
||||
|
||||
PRESET_REGISTRY.register({
|
||||
category: "movement",
|
||||
id: "double-pawn-sprint",
|
||||
name: "Perpetual Sprint",
|
||||
description:
|
||||
|
|
|
|||
|
|
@ -70,6 +70,7 @@ import { PRESET_REGISTRY } from "./registry.js";
|
|||
import type { EntityId } from "@paratype/rete";
|
||||
|
||||
PRESET_REGISTRY.register({
|
||||
category: "king",
|
||||
id: "dual-king",
|
||||
name: "Dual King",
|
||||
description:
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ function pieceAtSquare(session: Session, sq: Square): EntityId | null {
|
|||
}
|
||||
|
||||
PRESET_REGISTRY.register({
|
||||
category: "pieces",
|
||||
id: "explosive-rook",
|
||||
name: "Detonating Rook",
|
||||
description:
|
||||
|
|
|
|||
|
|
@ -144,6 +144,7 @@ interface ExtinctionChessState extends Record<string, unknown> {
|
|||
const DEFAULT_TARGET_TYPE: PieceType = "pawn";
|
||||
|
||||
PRESET_REGISTRY.register({
|
||||
category: "objective",
|
||||
id: "extinction-chess",
|
||||
name: "Extinction",
|
||||
description:
|
||||
|
|
|
|||
|
|
@ -118,6 +118,7 @@ interface FirstPromotionWinsState extends Record<string, unknown> {
|
|||
}
|
||||
|
||||
PRESET_REGISTRY.register({
|
||||
category: "objective",
|
||||
id: "first-promotion-wins",
|
||||
name: "First to Promote Wins",
|
||||
description:
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ function findKing(session: Session, color: PieceColor): EntityId | null {
|
|||
}
|
||||
|
||||
PRESET_REGISTRY.register({
|
||||
category: "pieces",
|
||||
id: "king-heals",
|
||||
name: "Regenerating King",
|
||||
description:
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { PRESET_REGISTRY } from "./registry.js";
|
||||
|
||||
PRESET_REGISTRY.register({
|
||||
category: "pieces",
|
||||
id: "knight-immunity",
|
||||
name: "Bishop-Proof Knights",
|
||||
description: "Knights are immune to capture by Bishops. Bishops cannot make moves that would capture a Knight.",
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ import { PRESET_REGISTRY } from "./registry.js";
|
|||
import type { EntityId } from "@paratype/rete";
|
||||
|
||||
PRESET_REGISTRY.register({
|
||||
category: "king",
|
||||
id: "knightmate-rules",
|
||||
name: "Knightmate",
|
||||
description:
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import { isAllyAt, isPieceAt } from "../rules/board-queries.js";
|
|||
import { PRESET_REGISTRY } from "./registry.js";
|
||||
|
||||
PRESET_REGISTRY.register({
|
||||
category: "movement",
|
||||
id: "knights-leap-twice",
|
||||
name: "Double-Leap Knights",
|
||||
description: "Knights move in two consecutive L-shapes in a single turn; the intermediate square must be empty.",
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ import { PRESET_REGISTRY } from "./registry.js";
|
|||
import type { GameResult } from "../engine.js";
|
||||
|
||||
PRESET_REGISTRY.register({
|
||||
category: "objective",
|
||||
id: "last-piece-standing",
|
||||
name: "Annihilation",
|
||||
description:
|
||||
|
|
|
|||
|
|
@ -63,6 +63,7 @@ import { PRESET_REGISTRY } from "./registry.js";
|
|||
const PRESET_ID = "monster-rules";
|
||||
|
||||
PRESET_REGISTRY.register({
|
||||
category: "multi-move",
|
||||
id: PRESET_ID,
|
||||
name: "Monster",
|
||||
description:
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ function getDiagonalQuietMoves(engine: ChessEngine, pieceId: EntityId): LegalMov
|
|||
}
|
||||
|
||||
PRESET_REGISTRY.register({
|
||||
category: "movement",
|
||||
id: "pawn-diagonal-no-capture",
|
||||
name: "Slanting Pawns",
|
||||
description:
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ function getPawnBackwardMove(engine: ChessEngine, pieceId: EntityId): LegalMove[
|
|||
}
|
||||
|
||||
PRESET_REGISTRY.register({
|
||||
category: "movement",
|
||||
id: "pawns-move-backward",
|
||||
name: "Backward-Marching Pawns",
|
||||
description:
|
||||
|
|
|
|||
|
|
@ -74,6 +74,7 @@ function iteratePieceIds(session: Session): EntityId[] {
|
|||
}
|
||||
|
||||
PRESET_REGISTRY.register({
|
||||
category: "pieces",
|
||||
id: "piece-hp",
|
||||
name: "Hit Points",
|
||||
description:
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import type { EntityId } from "@paratype/rete";
|
|||
export const POISONED_SQUARES: ReadonlySet<number> = new Set([27, 28, 35, 36]);
|
||||
|
||||
PRESET_REGISTRY.register({
|
||||
category: "pieces",
|
||||
id: "poisoned-squares",
|
||||
name: "Poisoned Centre",
|
||||
description:
|
||||
|
|
|
|||
|
|
@ -82,6 +82,7 @@ function pieceAtSquare(session: Session, sq: Square): EntityId | null {
|
|||
// on fission-spawned pieces automatically.
|
||||
|
||||
PRESET_REGISTRY.register({
|
||||
category: "movement",
|
||||
id: "queen-splits",
|
||||
name: "Queen Fission",
|
||||
description:
|
||||
|
|
|
|||
|
|
@ -328,6 +328,11 @@ export interface PresetDef {
|
|||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly description: string;
|
||||
/**
|
||||
* The category this preset belongs to for UI grouping.
|
||||
* Defaults to "misc" if not specified.
|
||||
*/
|
||||
readonly category?: "king" | "objective" | "movement" | "multi-move" | "pieces" | "misc";
|
||||
/** Preset IDs whose activation must NOT overlap with this one. */
|
||||
readonly incompatibleWith: readonly string[];
|
||||
/** Preset IDs that must also be active for this one to be valid. */
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ function warpDest(
|
|||
}
|
||||
|
||||
PRESET_REGISTRY.register({
|
||||
category: "movement",
|
||||
id: "rook-warp",
|
||||
name: "Rook Warp",
|
||||
description: "After a rook's normal slide, it may warp around the board edge to the opposite end of its rank or file.",
|
||||
|
|
|
|||
|
|
@ -126,6 +126,7 @@ import { PRESET_REGISTRY } from "./registry.js";
|
|||
import type { ChessEngine, GameResult } from "../engine.js";
|
||||
|
||||
PRESET_REGISTRY.register({
|
||||
category: "objective",
|
||||
id: "suicide-chess",
|
||||
name: "Suicide Chess (Antichess)",
|
||||
description:
|
||||
|
|
|
|||
|
|
@ -219,6 +219,7 @@ function hasSavingMove(engine: ChessEngine): boolean {
|
|||
}
|
||||
|
||||
PRESET_REGISTRY.register({
|
||||
category: "king",
|
||||
id: "weak-dual-king",
|
||||
name: "Weak Dual King",
|
||||
description:
|
||||
|
|
|
|||
|
|
@ -202,6 +202,7 @@ function computeCylinderMoves(
|
|||
}
|
||||
|
||||
PRESET_REGISTRY.register({
|
||||
category: "movement",
|
||||
id: "wrap-board",
|
||||
name: "Cylindrical Board",
|
||||
description:
|
||||
|
|
|
|||
|
|
@ -22,15 +22,21 @@
|
|||
import { useMemo } from 'react';
|
||||
import {
|
||||
LAYOUT_REGISTRY,
|
||||
PRESET_REGISTRY,
|
||||
buildChess960Layout,
|
||||
type StartingLayout,
|
||||
} from '@paratype/chess';
|
||||
import type { PresetActivation } from '../net/types.js';
|
||||
|
||||
export interface LayoutPickerProps {
|
||||
/** Currently selected layout. Parents own the state. */
|
||||
value: StartingLayout;
|
||||
/** Called when the user picks a new premade. */
|
||||
onChange: (layout: StartingLayout) => void;
|
||||
/** Current authoritative preset activations (for toggling suggested rules). */
|
||||
activations?: readonly PresetActivation[];
|
||||
/** Replace the active set on the engine / server. */
|
||||
setPresets?: (activations: PresetActivation[]) => void;
|
||||
/** Called when the user picks the "Custom…" entry. */
|
||||
onCustomRequested?: () => void;
|
||||
/** Disables the control (during network requests, etc). */
|
||||
|
|
@ -40,6 +46,8 @@ export interface LayoutPickerProps {
|
|||
export function LayoutPicker({
|
||||
value,
|
||||
onChange,
|
||||
activations = [],
|
||||
setPresets,
|
||||
onCustomRequested,
|
||||
disabled = false,
|
||||
}: LayoutPickerProps) {
|
||||
|
|
@ -75,6 +83,16 @@ export function LayoutPicker({
|
|||
}
|
||||
}
|
||||
|
||||
const togglePreset = (presetId: string) => {
|
||||
if (!setPresets) return;
|
||||
const isActive = activations.some(a => a.id === presetId);
|
||||
if (isActive) {
|
||||
setPresets(activations.filter(a => a.id !== presetId));
|
||||
} else {
|
||||
setPresets([...activations, { id: presetId, scope: 'both', turnsRemaining: null }]);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<label className="block text-xs font-bold text-neutral-500 uppercase tracking-widest">
|
||||
|
|
@ -124,9 +142,31 @@ export function LayoutPicker({
|
|||
</p>
|
||||
{value.suggestedPresets !== undefined &&
|
||||
value.suggestedPresets.length > 0 && (
|
||||
<p className="text-xs text-neutral-400 italic">
|
||||
Suggested rules: {value.suggestedPresets.join(', ')}
|
||||
</p>
|
||||
<div className="pt-2 flex flex-wrap items-center gap-1.5">
|
||||
<span className="text-xs text-neutral-400 italic mr-1">Suggested rules:</span>
|
||||
{value.suggestedPresets.map((presetId) => {
|
||||
const presetDef = PRESET_REGISTRY.get(presetId);
|
||||
const label = presetDef?.name ?? presetId;
|
||||
const isActive = activations.some(a => a.id === presetId);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={presetId}
|
||||
data-testid={`layout-suggested-preset-${presetId}`}
|
||||
type="button"
|
||||
onClick={() => togglePreset(presetId)}
|
||||
disabled={disabled || !setPresets}
|
||||
className={`inline-flex px-2 py-0.5 rounded-full text-xs transition-colors border ${
|
||||
isActive
|
||||
? 'bg-neutral-800 text-white border-neutral-800 hover:bg-neutral-700'
|
||||
: 'bg-neutral-100 text-neutral-600 border-neutral-200 hover:bg-neutral-200'
|
||||
} disabled:opacity-50 disabled:cursor-not-allowed`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
validateLayout,
|
||||
type StartingLayout,
|
||||
} from '@paratype/chess';
|
||||
import type { PresetActivation } from '../net/types.js';
|
||||
import type { LayoutRequest } from '../net/types';
|
||||
import { LayoutPicker } from './LayoutPicker';
|
||||
import { LayoutEditor } from './LayoutEditor';
|
||||
|
|
@ -90,6 +91,9 @@ export function Lobby({ chessState }: LobbyProps = {}) {
|
|||
* wire — the server protocol carries one ModifierProfile per room
|
||||
* (T2). Multi-stack on the wire is a future T27+ extension.
|
||||
*/
|
||||
// Suggested rules driven by LayoutPicker
|
||||
const [presets, setPresets] = useState<PresetActivation[]>([]);
|
||||
|
||||
const [additionalProfiles, setAdditionalProfiles] = useState<
|
||||
ModifierProfile[]
|
||||
>([]);
|
||||
|
|
@ -301,6 +305,9 @@ export function Lobby({ chessState }: LobbyProps = {}) {
|
|||
if (selectedProfile !== null) {
|
||||
createPayload.profile = selectedProfile;
|
||||
}
|
||||
if (presets.length > 0) {
|
||||
createPayload.rulesetIds = presets.map(p => p.id);
|
||||
}
|
||||
|
||||
const { code, token, color, layout: resolvedLayout, profile: echoedProfile } =
|
||||
await oneShotRoomRequest('room.create', createPayload);
|
||||
|
|
@ -428,6 +435,8 @@ export function Lobby({ chessState }: LobbyProps = {}) {
|
|||
<LayoutPicker
|
||||
value={selectedLayout}
|
||||
onChange={setSelectedLayout}
|
||||
activations={presets}
|
||||
setPresets={setPresets}
|
||||
onCustomRequested={() => setEditorOpen(true)}
|
||||
disabled={loading}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -223,7 +223,7 @@ export function RulesDrawer({
|
|||
data-testid="rules-drawer"
|
||||
className="fixed top-0 right-0 h-full w-full max-w-md bg-white shadow-2xl z-50 flex flex-col overflow-hidden border-l border-neutral-200/50"
|
||||
>
|
||||
<header className="px-6 py-5 border-b border-neutral-100 flex items-center justify-between bg-white">
|
||||
<header className="px-6 py-5 border-b border-neutral-100 flex items-center justify-between bg-white shrink-0">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold tracking-tight text-neutral-900">
|
||||
Live Rules
|
||||
|
|
@ -243,272 +243,293 @@ export function RulesDrawer({
|
|||
</button>
|
||||
</header>
|
||||
|
||||
<div className="flex-1 overflow-y-auto overscroll-contain p-6 space-y-4">
|
||||
{presets.map((preset) => {
|
||||
const active = activeById.get(preset.id);
|
||||
const isOn = active !== undefined;
|
||||
|
||||
// Compute whether this preset CAN be toggled right now
|
||||
// given the currently-active set. Pre-checking mirrors
|
||||
// the engine's own validation in
|
||||
// `ActivePresetSet.replaceAll` so we don't round-trip
|
||||
// to the server just to get an error back (which
|
||||
// would produce a misleading "Preset enabled" toast
|
||||
// followed by an error banner). Scope defaults to
|
||||
// 'both', so overlap is always true for this quick
|
||||
// check.
|
||||
//
|
||||
// Enable block: a currently-active preset conflicts,
|
||||
// OR a required preset isn't active.
|
||||
// Disable block: another active preset REQUIRES this
|
||||
// one. (Turning off piece-hp while
|
||||
// king-heals is on would fail.)
|
||||
//
|
||||
// Returns null when the toggle action would succeed,
|
||||
// otherwise a structured reason used to drive the
|
||||
// disabled toggle + tooltip.
|
||||
let blockReason:
|
||||
| null
|
||||
| { kind: 'conflicts'; withName: string }
|
||||
| { kind: 'missing'; needsName: string }
|
||||
| { kind: 'depended-on-by'; dependentName: string } = null;
|
||||
if (!isOn) {
|
||||
for (const otherId of preset.incompatibleWith) {
|
||||
if (activeById.has(otherId)) {
|
||||
blockReason = {
|
||||
kind: 'conflicts',
|
||||
withName: nameById(otherId),
|
||||
};
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (blockReason === null) {
|
||||
for (const depId of preset.requires) {
|
||||
if (!activeById.has(depId)) {
|
||||
blockReason = {
|
||||
kind: 'missing',
|
||||
needsName: nameById(depId),
|
||||
};
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Disable path: check whether any OTHER active
|
||||
// preset requires this one. If so, disabling would
|
||||
// leave that preset with an unmet dependency and
|
||||
// the server / engine would reject the whole set.
|
||||
for (const other of presets) {
|
||||
if (!activeById.has(other.id)) continue;
|
||||
if (other.id === preset.id) continue;
|
||||
if (other.requires.includes(preset.id)) {
|
||||
blockReason = {
|
||||
kind: 'depended-on-by',
|
||||
dependentName: other.name,
|
||||
};
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
const toggleDisabled = blockReason !== null;
|
||||
const blockMessage =
|
||||
blockReason === null
|
||||
? undefined
|
||||
: blockReason.kind === 'conflicts'
|
||||
? `Disable "${blockReason.withName}" first — it conflicts with this rule`
|
||||
: blockReason.kind === 'missing'
|
||||
? `Enable "${blockReason.needsName}" first — this rule requires it`
|
||||
: `Disable "${blockReason.dependentName}" first — it requires this rule`;
|
||||
<div className="flex-1 overflow-y-auto overscroll-contain p-6">
|
||||
{['king', 'objective', 'movement', 'multi-move', 'pieces', 'misc'].map((categoryId) => {
|
||||
const categoryPresets = presets.filter(p => (p.category ?? 'misc') === categoryId);
|
||||
if (categoryPresets.length === 0) return null;
|
||||
|
||||
const categoryName = {
|
||||
'king': 'King Variants',
|
||||
'objective': 'Objectives',
|
||||
'movement': 'Movement',
|
||||
'multi-move': 'Multi-move',
|
||||
'pieces': 'Pieces',
|
||||
'misc': 'Misc'
|
||||
}[categoryId];
|
||||
|
||||
return (
|
||||
<div
|
||||
key={preset.id}
|
||||
data-preset={preset.id}
|
||||
className={`border rounded-xl p-4 transition-all duration-200 ${
|
||||
isOn
|
||||
? 'border-neutral-300 bg-neutral-50 shadow-sm'
|
||||
: 'border-neutral-200 hover:border-neutral-300'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-neutral-900 text-sm">
|
||||
{preset.name}
|
||||
</h3>
|
||||
<p className="text-sm text-neutral-500 mt-1 leading-relaxed">
|
||||
{preset.description}
|
||||
</p>
|
||||
{(preset.requires.length > 0 ||
|
||||
preset.incompatibleWith.length > 0) && (
|
||||
<div
|
||||
className="mt-2 flex flex-wrap gap-1.5"
|
||||
data-role="deps"
|
||||
>
|
||||
{preset.requires.map((depId) => {
|
||||
const depActive = activeById.has(depId);
|
||||
return (
|
||||
<span
|
||||
key={`req-${depId}`}
|
||||
data-role="dep-requires"
|
||||
data-dep-id={depId}
|
||||
data-dep-satisfied={depActive}
|
||||
title={
|
||||
depActive
|
||||
? `Requires ${nameById(depId)} (active)`
|
||||
: `Requires ${nameById(depId)} — enable this first`
|
||||
}
|
||||
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium border ${
|
||||
depActive
|
||||
? 'bg-emerald-50 border-emerald-200 text-emerald-700'
|
||||
: 'bg-amber-50 border-amber-200 text-amber-700'
|
||||
}`}
|
||||
>
|
||||
<span aria-hidden="true">
|
||||
{depActive ? '✓' : '!'}
|
||||
</span>
|
||||
Requires {nameById(depId)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
{preset.incompatibleWith.map((otherId) => {
|
||||
const otherActive = activeById.has(otherId);
|
||||
return (
|
||||
<span
|
||||
key={`inc-${otherId}`}
|
||||
data-role="dep-conflicts"
|
||||
data-dep-id={otherId}
|
||||
data-dep-conflicting={otherActive}
|
||||
title={
|
||||
otherActive
|
||||
? `Conflicts with ${nameById(otherId)} (active) — enabling this will clash`
|
||||
: `Conflicts with ${nameById(otherId)}`
|
||||
}
|
||||
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium border ${
|
||||
otherActive
|
||||
? 'bg-red-50 border-red-200 text-red-700'
|
||||
: 'bg-neutral-100 border-neutral-200 text-neutral-600'
|
||||
}`}
|
||||
>
|
||||
<span aria-hidden="true">⊗</span>
|
||||
Conflicts with {nameById(otherId)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
data-role="toggle"
|
||||
data-disabled-reason={
|
||||
blockReason === null ? undefined : blockReason.kind
|
||||
}
|
||||
onClick={() => toggle(preset.id, preset.name)}
|
||||
disabled={toggleDisabled}
|
||||
title={blockMessage}
|
||||
aria-label={
|
||||
blockMessage !== undefined
|
||||
? `${preset.name} — ${blockMessage}`
|
||||
: `${preset.name} toggle`
|
||||
}
|
||||
className={`relative inline-flex h-6 w-11 flex-shrink-0 rounded-full border-2 border-transparent transition-colors focus:outline-none focus:ring-2 focus:ring-neutral-900 focus:ring-offset-2 focus:ring-offset-white ${
|
||||
toggleDisabled
|
||||
? 'cursor-not-allowed opacity-40 bg-neutral-200'
|
||||
: `cursor-pointer ${
|
||||
isOn ? 'bg-neutral-900' : 'bg-neutral-200'
|
||||
}`
|
||||
}`}
|
||||
role="switch"
|
||||
aria-checked={isOn}
|
||||
aria-disabled={toggleDisabled}
|
||||
>
|
||||
<motion.span
|
||||
layout
|
||||
aria-hidden="true"
|
||||
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow-sm ring-0 transition duration-200 ease-in-out ${
|
||||
isOn ? 'translate-x-5' : 'translate-x-0'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<div key={categoryId} className="mb-8 last:mb-0" data-testid={`rules-category-${categoryId}`}>
|
||||
<h3 className="text-xs font-bold text-neutral-400 uppercase tracking-wider mb-4 px-1">{categoryName}</h3>
|
||||
<div className="space-y-4">
|
||||
{categoryPresets.map((preset) => {
|
||||
const active = activeById.get(preset.id);
|
||||
const isOn = active !== undefined;
|
||||
|
||||
{isOn && (
|
||||
<div className="mt-4 pt-4 border-t border-neutral-200 space-y-3">
|
||||
{/* Scope radios */}
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<label className="text-xs font-semibold text-neutral-600 uppercase tracking-wide">
|
||||
Apply to
|
||||
</label>
|
||||
// Compute whether this preset CAN be toggled right now
|
||||
// given the currently-active set. Pre-checking mirrors
|
||||
// the engine's own validation in
|
||||
// `ActivePresetSet.replaceAll` so we don't round-trip
|
||||
// to the server just to get an error back (which
|
||||
// would produce a misleading "Preset enabled" toast
|
||||
// followed by an error banner). Scope defaults to
|
||||
// 'both', so overlap is always true for this quick
|
||||
// check.
|
||||
//
|
||||
// Enable block: a currently-active preset conflicts,
|
||||
// OR a required preset isn't active.
|
||||
// Disable block: another active preset REQUIRES this
|
||||
// one. (Turning off piece-hp while
|
||||
// king-heals is on would fail.)
|
||||
//
|
||||
// Returns null when the toggle action would succeed,
|
||||
// otherwise a structured reason used to drive the
|
||||
// disabled toggle + tooltip.
|
||||
let blockReason:
|
||||
| null
|
||||
| { kind: 'conflicts'; withName: string }
|
||||
| { kind: 'missing'; needsName: string }
|
||||
| { kind: 'depended-on-by'; dependentName: string } = null;
|
||||
if (!isOn) {
|
||||
for (const otherId of preset.incompatibleWith) {
|
||||
if (activeById.has(otherId)) {
|
||||
blockReason = {
|
||||
kind: 'conflicts',
|
||||
withName: nameById(otherId),
|
||||
};
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (blockReason === null) {
|
||||
for (const depId of preset.requires) {
|
||||
if (!activeById.has(depId)) {
|
||||
blockReason = {
|
||||
kind: 'missing',
|
||||
needsName: nameById(depId),
|
||||
};
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Disable path: check whether any OTHER active
|
||||
// preset requires this one. If so, disabling would
|
||||
// leave that preset with an unmet dependency and
|
||||
// the server / engine would reject the whole set.
|
||||
for (const other of presets) {
|
||||
if (!activeById.has(other.id)) continue;
|
||||
if (other.id === preset.id) continue;
|
||||
if (other.requires.includes(preset.id)) {
|
||||
blockReason = {
|
||||
kind: 'depended-on-by',
|
||||
dependentName: other.name,
|
||||
};
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
const toggleDisabled = blockReason !== null;
|
||||
const blockMessage =
|
||||
blockReason === null
|
||||
? undefined
|
||||
: blockReason.kind === 'conflicts'
|
||||
? `Disable "${blockReason.withName}" first — it conflicts with this rule`
|
||||
: blockReason.kind === 'missing'
|
||||
? `Enable "${blockReason.needsName}" first — this rule requires it`
|
||||
: `Disable "${blockReason.dependentName}" first — it requires this rule`;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex rounded-lg bg-neutral-100 p-0.5 text-xs font-medium"
|
||||
role="radiogroup"
|
||||
aria-label={`${preset.name} scope`}
|
||||
data-role="scope-group"
|
||||
key={preset.id}
|
||||
data-preset={preset.id}
|
||||
className={`border rounded-xl p-4 transition-all duration-200 ${
|
||||
isOn
|
||||
? 'border-neutral-300 bg-neutral-50 shadow-sm'
|
||||
: 'border-neutral-200 hover:border-neutral-300'
|
||||
}`}
|
||||
>
|
||||
{(['both', 'white', 'black'] as const).map(
|
||||
(scope) => {
|
||||
const selected = active.scope === scope;
|
||||
return (
|
||||
<button
|
||||
key={scope}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={selected}
|
||||
data-role={`scope-${scope}`}
|
||||
onClick={() => setScope(preset.id, scope)}
|
||||
className={`px-3 py-1 rounded-md transition-colors capitalize ${
|
||||
selected
|
||||
? 'bg-white text-neutral-900 shadow-sm'
|
||||
: 'text-neutral-500 hover:text-neutral-800'
|
||||
}`}
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-neutral-900 text-sm">
|
||||
{preset.name}
|
||||
</h3>
|
||||
<p className="text-sm text-neutral-500 mt-1 leading-relaxed">
|
||||
{preset.description}
|
||||
</p>
|
||||
{(preset.requires.length > 0 ||
|
||||
preset.incompatibleWith.length > 0) && (
|
||||
<div
|
||||
className="mt-2 flex flex-wrap gap-1.5"
|
||||
data-role="deps"
|
||||
>
|
||||
{scope}
|
||||
</button>
|
||||
);
|
||||
},
|
||||
{preset.requires.map((depId) => {
|
||||
const depActive = activeById.has(depId);
|
||||
return (
|
||||
<span
|
||||
key={`req-${depId}`}
|
||||
data-role="dep-requires"
|
||||
data-dep-id={depId}
|
||||
data-dep-satisfied={depActive}
|
||||
title={
|
||||
depActive
|
||||
? `Requires ${nameById(depId)} (active)`
|
||||
: `Requires ${nameById(depId)} — enable this first`
|
||||
}
|
||||
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium border ${
|
||||
depActive
|
||||
? 'bg-emerald-50 border-emerald-200 text-emerald-700'
|
||||
: 'bg-amber-50 border-amber-200 text-amber-700'
|
||||
}`}
|
||||
>
|
||||
<span aria-hidden="true">
|
||||
{depActive ? '✓' : '!'}
|
||||
</span>
|
||||
Requires {nameById(depId)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
{preset.incompatibleWith.map((otherId) => {
|
||||
const otherActive = activeById.has(otherId);
|
||||
return (
|
||||
<span
|
||||
key={`inc-${otherId}`}
|
||||
data-role="dep-conflicts"
|
||||
data-dep-id={otherId}
|
||||
data-dep-conflicting={otherActive}
|
||||
title={
|
||||
otherActive
|
||||
? `Conflicts with ${nameById(otherId)} (active) — enabling this will clash`
|
||||
: `Conflicts with ${nameById(otherId)}`
|
||||
}
|
||||
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium border ${
|
||||
otherActive
|
||||
? 'bg-red-50 border-red-200 text-red-700'
|
||||
: 'bg-neutral-100 border-neutral-200 text-neutral-600'
|
||||
}`}
|
||||
>
|
||||
<span aria-hidden="true">⊗</span>
|
||||
Conflicts with {nameById(otherId)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
data-role="toggle"
|
||||
data-disabled-reason={
|
||||
blockReason === null ? undefined : blockReason.kind
|
||||
}
|
||||
onClick={() => toggle(preset.id, preset.name)}
|
||||
disabled={toggleDisabled}
|
||||
title={blockMessage}
|
||||
aria-label={
|
||||
blockMessage !== undefined
|
||||
? `${preset.name} — ${blockMessage}`
|
||||
: `${preset.name} toggle`
|
||||
}
|
||||
className={`relative inline-flex h-6 w-11 flex-shrink-0 rounded-full border-2 border-transparent transition-colors focus:outline-none focus:ring-2 focus:ring-neutral-900 focus:ring-offset-2 focus:ring-offset-white ${
|
||||
toggleDisabled
|
||||
? 'cursor-not-allowed opacity-40 bg-neutral-200'
|
||||
: `cursor-pointer ${
|
||||
isOn ? 'bg-neutral-900' : 'bg-neutral-200'
|
||||
}`
|
||||
}`}
|
||||
role="switch"
|
||||
aria-checked={isOn}
|
||||
aria-disabled={toggleDisabled}
|
||||
>
|
||||
<motion.span
|
||||
layout
|
||||
aria-hidden="true"
|
||||
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow-sm ring-0 transition duration-200 ease-in-out ${
|
||||
isOn ? 'translate-x-5' : 'translate-x-0'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isOn && (
|
||||
<div className="mt-4 pt-4 border-t border-neutral-200 space-y-3">
|
||||
{/* Scope radios */}
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<label className="text-xs font-semibold text-neutral-600 uppercase tracking-wide">
|
||||
Apply to
|
||||
</label>
|
||||
<div
|
||||
className="flex rounded-lg bg-neutral-100 p-0.5 text-xs font-medium"
|
||||
role="radiogroup"
|
||||
aria-label={`${preset.name} scope`}
|
||||
data-role="scope-group"
|
||||
>
|
||||
{(['both', 'white', 'black'] as const).map(
|
||||
(scope) => {
|
||||
const selected = active.scope === scope;
|
||||
return (
|
||||
<button
|
||||
key={scope}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={selected}
|
||||
data-role={`scope-${scope}`}
|
||||
onClick={() => setScope(preset.id, scope)}
|
||||
className={`px-3 py-1 rounded-md transition-colors capitalize ${
|
||||
selected
|
||||
? 'bg-white text-neutral-900 shadow-sm'
|
||||
: 'text-neutral-500 hover:text-neutral-800'
|
||||
}`}
|
||||
>
|
||||
{scope}
|
||||
</button>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Duration input */}
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<label
|
||||
htmlFor={`duration-${preset.id}`}
|
||||
className="text-xs font-semibold text-neutral-600 uppercase tracking-wide"
|
||||
>
|
||||
Duration
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
id={`duration-${preset.id}`}
|
||||
data-role="duration-input"
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
placeholder="∞"
|
||||
value={
|
||||
active.turnsRemaining === null
|
||||
? ''
|
||||
: String(active.turnsRemaining)
|
||||
}
|
||||
onChange={(e) => setDuration(preset.id, e)}
|
||||
className="w-20 px-2 py-1 border border-neutral-300 rounded-md text-sm text-right focus:outline-none focus:ring-2 focus:ring-neutral-900 focus:border-transparent"
|
||||
/>
|
||||
<span className="text-xs text-neutral-500">
|
||||
{active.turnsRemaining === null
|
||||
? 'permanent'
|
||||
: 'turns'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Duration input */}
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<label
|
||||
htmlFor={`duration-${preset.id}`}
|
||||
className="text-xs font-semibold text-neutral-600 uppercase tracking-wide"
|
||||
>
|
||||
Duration
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
id={`duration-${preset.id}`}
|
||||
data-role="duration-input"
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
placeholder="∞"
|
||||
value={
|
||||
active.turnsRemaining === null
|
||||
? ''
|
||||
: String(active.turnsRemaining)
|
||||
}
|
||||
onChange={(e) => setDuration(preset.id, e)}
|
||||
className="w-20 px-2 py-1 border border-neutral-300 rounded-md text-sm text-right focus:outline-none focus:ring-2 focus:ring-neutral-900 focus:border-transparent"
|
||||
/>
|
||||
<span className="text-xs text-neutral-500">
|
||||
{active.turnsRemaining === null
|
||||
? 'permanent'
|
||||
: 'turns'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<footer className="p-4 border-t border-neutral-100 bg-white space-y-3">
|
||||
<footer className="p-4 border-t border-neutral-100 bg-white space-y-3 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="open-modifier-editor"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue