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:
Joey Yakimowich-Payne 2026-04-21 09:19:38 -06:00
commit bd20032767
No known key found for this signature in database
33 changed files with 361 additions and 257 deletions

View file

@ -94,6 +94,7 @@ function emitMove(
}
PRESET_REGISTRY.register({
category: "movement",
id: PRESET_ID,
name: "Berolina Pawns (Extended)",
description:

View file

@ -134,6 +134,7 @@ function emitMove(
}
PRESET_REGISTRY.register({
category: "movement",
id: PRESET_ID,
name: "Berolina Pawns",
description:

View file

@ -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.",

View file

@ -192,6 +192,7 @@ function walkBouncingRay(
}
PRESET_REGISTRY.register({
category: "movement",
id: "bouncing-pieces-2",
name: "Bouncing Pieces (Extended)",
description:

View file

@ -203,6 +203,7 @@ function walkBouncingRay(
}
PRESET_REGISTRY.register({
category: "movement",
id: "bouncing-pieces",
name: "Bouncing Pieces",
description:

View file

@ -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.",

View file

@ -34,6 +34,7 @@ interface CaptureToWinState extends Record<string, unknown> {
}
PRESET_REGISTRY.register({
category: "objective",
id: "capture-to-win",
name: "First Blood",
description:

View file

@ -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:

View file

@ -75,6 +75,7 @@
import { PRESET_REGISTRY } from "./registry.js";
PRESET_REGISTRY.register({
category: "multi-move",
id: "double-move",
name: "Double Move",
description:

View file

@ -54,6 +54,7 @@ function getDoubleSprintMove(engine: ChessEngine, pieceId: EntityId): LegalMove[
}
PRESET_REGISTRY.register({
category: "movement",
id: "double-pawn-sprint",
name: "Perpetual Sprint",
description:

View file

@ -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:

View file

@ -39,6 +39,7 @@ function pieceAtSquare(session: Session, sq: Square): EntityId | null {
}
PRESET_REGISTRY.register({
category: "pieces",
id: "explosive-rook",
name: "Detonating Rook",
description:

View file

@ -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:

View file

@ -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:

View file

@ -35,6 +35,7 @@ function findKing(session: Session, color: PieceColor): EntityId | null {
}
PRESET_REGISTRY.register({
category: "pieces",
id: "king-heals",
name: "Regenerating King",
description:

View file

@ -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.",

View file

@ -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:

View file

@ -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.",

View file

@ -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:

View file

@ -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:

View file

@ -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:

View file

@ -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:

View file

@ -74,6 +74,7 @@ function iteratePieceIds(session: Session): EntityId[] {
}
PRESET_REGISTRY.register({
category: "pieces",
id: "piece-hp",
name: "Hit Points",
description:

View file

@ -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:

View file

@ -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:

View file

@ -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. */

View file

@ -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.",

View 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:

View file

@ -219,6 +219,7 @@ function hasSavingMove(engine: ChessEngine): boolean {
}
PRESET_REGISTRY.register({
category: "king",
id: "weak-dual-king",
name: "Weak Dual King",
description:

View file

@ -202,6 +202,7 @@ function computeCylinderMoves(
}
PRESET_REGISTRY.register({
category: "movement",
id: "wrap-board",
name: "Cylindrical Board",
description:

View file

@ -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>
);

View file

@ -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}
/>

View file

@ -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,8 +243,25 @@ export function RulesDrawer({
</button>
</header>
<div className="flex-1 overflow-y-auto overscroll-contain p-6 space-y-4">
{presets.map((preset) => {
<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={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;
@ -507,8 +524,12 @@ export function RulesDrawer({
);
})}
</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"