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({ PRESET_REGISTRY.register({
category: "movement",
id: PRESET_ID, id: PRESET_ID,
name: "Berolina Pawns (Extended)", name: "Berolina Pawns (Extended)",
description: description:

View file

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

View file

@ -18,6 +18,7 @@ const ORTHOGONAL_DELTAS: ReadonlyArray<readonly [number, number]> = [
]; ];
PRESET_REGISTRY.register({ PRESET_REGISTRY.register({
category: "movement",
id: "bishops-ignore-color", id: "bishops-ignore-color",
name: "Colour-Blind Bishops", name: "Colour-Blind Bishops",
description: "Bishops may additionally step one square orthogonally to an empty or enemy-occupied square.", 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({ PRESET_REGISTRY.register({
category: "movement",
id: "bouncing-pieces-2", id: "bouncing-pieces-2",
name: "Bouncing Pieces (Extended)", name: "Bouncing Pieces (Extended)",
description: description:

View file

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

View file

@ -94,6 +94,7 @@ import { PRESET_REGISTRY } from "./registry.js";
import type { GameResult } from "../engine.js"; import type { GameResult } from "../engine.js";
PRESET_REGISTRY.register({ PRESET_REGISTRY.register({
category: "objective",
id: "capture-all", id: "capture-all",
name: "Capture All", name: "Capture All",
description: "Capture every enemy piece to win. Kings are not royal.", 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({ PRESET_REGISTRY.register({
category: "objective",
id: "capture-to-win", id: "capture-to-win",
name: "First Blood", name: "First Blood",
description: description:

View file

@ -64,6 +64,7 @@ import { PRESET_REGISTRY } from "./registry.js";
import type { EntityId } from "@paratype/rete"; import type { EntityId } from "@paratype/rete";
PRESET_REGISTRY.register({ PRESET_REGISTRY.register({
category: "king",
id: "coregal", id: "coregal",
name: "Coregal", name: "Coregal",
description: description:

View file

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

View file

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

View file

@ -70,6 +70,7 @@ import { PRESET_REGISTRY } from "./registry.js";
import type { EntityId } from "@paratype/rete"; import type { EntityId } from "@paratype/rete";
PRESET_REGISTRY.register({ PRESET_REGISTRY.register({
category: "king",
id: "dual-king", id: "dual-king",
name: "Dual King", name: "Dual King",
description: description:

View file

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

View file

@ -144,6 +144,7 @@ interface ExtinctionChessState extends Record<string, unknown> {
const DEFAULT_TARGET_TYPE: PieceType = "pawn"; const DEFAULT_TARGET_TYPE: PieceType = "pawn";
PRESET_REGISTRY.register({ PRESET_REGISTRY.register({
category: "objective",
id: "extinction-chess", id: "extinction-chess",
name: "Extinction", name: "Extinction",
description: description:

View file

@ -118,6 +118,7 @@ interface FirstPromotionWinsState extends Record<string, unknown> {
} }
PRESET_REGISTRY.register({ PRESET_REGISTRY.register({
category: "objective",
id: "first-promotion-wins", id: "first-promotion-wins",
name: "First to Promote Wins", name: "First to Promote Wins",
description: description:

View file

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

View file

@ -1,6 +1,7 @@
import { PRESET_REGISTRY } from "./registry.js"; import { PRESET_REGISTRY } from "./registry.js";
PRESET_REGISTRY.register({ PRESET_REGISTRY.register({
category: "pieces",
id: "knight-immunity", id: "knight-immunity",
name: "Bishop-Proof Knights", name: "Bishop-Proof Knights",
description: "Knights are immune to capture by Bishops. Bishops cannot make moves that would capture a Knight.", 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"; import type { EntityId } from "@paratype/rete";
PRESET_REGISTRY.register({ PRESET_REGISTRY.register({
category: "king",
id: "knightmate-rules", id: "knightmate-rules",
name: "Knightmate", name: "Knightmate",
description: description:

View file

@ -12,6 +12,7 @@ import { isAllyAt, isPieceAt } from "../rules/board-queries.js";
import { PRESET_REGISTRY } from "./registry.js"; import { PRESET_REGISTRY } from "./registry.js";
PRESET_REGISTRY.register({ PRESET_REGISTRY.register({
category: "movement",
id: "knights-leap-twice", id: "knights-leap-twice",
name: "Double-Leap Knights", name: "Double-Leap Knights",
description: "Knights move in two consecutive L-shapes in a single turn; the intermediate square must be empty.", 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"; import type { GameResult } from "../engine.js";
PRESET_REGISTRY.register({ PRESET_REGISTRY.register({
category: "objective",
id: "last-piece-standing", id: "last-piece-standing",
name: "Annihilation", name: "Annihilation",
description: description:

View file

@ -63,6 +63,7 @@ import { PRESET_REGISTRY } from "./registry.js";
const PRESET_ID = "monster-rules"; const PRESET_ID = "monster-rules";
PRESET_REGISTRY.register({ PRESET_REGISTRY.register({
category: "multi-move",
id: PRESET_ID, id: PRESET_ID,
name: "Monster", name: "Monster",
description: description:

View file

@ -40,6 +40,7 @@ function getDiagonalQuietMoves(engine: ChessEngine, pieceId: EntityId): LegalMov
} }
PRESET_REGISTRY.register({ PRESET_REGISTRY.register({
category: "movement",
id: "pawn-diagonal-no-capture", id: "pawn-diagonal-no-capture",
name: "Slanting Pawns", name: "Slanting Pawns",
description: description:

View file

@ -40,6 +40,7 @@ function getPawnBackwardMove(engine: ChessEngine, pieceId: EntityId): LegalMove[
} }
PRESET_REGISTRY.register({ PRESET_REGISTRY.register({
category: "movement",
id: "pawns-move-backward", id: "pawns-move-backward",
name: "Backward-Marching Pawns", name: "Backward-Marching Pawns",
description: description:

View file

@ -74,6 +74,7 @@ function iteratePieceIds(session: Session): EntityId[] {
} }
PRESET_REGISTRY.register({ PRESET_REGISTRY.register({
category: "pieces",
id: "piece-hp", id: "piece-hp",
name: "Hit Points", name: "Hit Points",
description: 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]); export const POISONED_SQUARES: ReadonlySet<number> = new Set([27, 28, 35, 36]);
PRESET_REGISTRY.register({ PRESET_REGISTRY.register({
category: "pieces",
id: "poisoned-squares", id: "poisoned-squares",
name: "Poisoned Centre", name: "Poisoned Centre",
description: description:

View file

@ -82,6 +82,7 @@ function pieceAtSquare(session: Session, sq: Square): EntityId | null {
// on fission-spawned pieces automatically. // on fission-spawned pieces automatically.
PRESET_REGISTRY.register({ PRESET_REGISTRY.register({
category: "movement",
id: "queen-splits", id: "queen-splits",
name: "Queen Fission", name: "Queen Fission",
description: description:

View file

@ -328,6 +328,11 @@ export interface PresetDef {
readonly id: string; readonly id: string;
readonly name: string; readonly name: string;
readonly description: 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. */ /** Preset IDs whose activation must NOT overlap with this one. */
readonly incompatibleWith: readonly string[]; readonly incompatibleWith: readonly string[];
/** Preset IDs that must also be active for this one to be valid. */ /** Preset IDs that must also be active for this one to be valid. */

View file

@ -36,6 +36,7 @@ function warpDest(
} }
PRESET_REGISTRY.register({ PRESET_REGISTRY.register({
category: "movement",
id: "rook-warp", id: "rook-warp",
name: "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.", 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"; import type { ChessEngine, GameResult } from "../engine.js";
PRESET_REGISTRY.register({ PRESET_REGISTRY.register({
category: "objective",
id: "suicide-chess", id: "suicide-chess",
name: "Suicide Chess (Antichess)", name: "Suicide Chess (Antichess)",
description: description:

View file

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

View file

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

View file

@ -22,15 +22,21 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { import {
LAYOUT_REGISTRY, LAYOUT_REGISTRY,
PRESET_REGISTRY,
buildChess960Layout, buildChess960Layout,
type StartingLayout, type StartingLayout,
} from '@paratype/chess'; } from '@paratype/chess';
import type { PresetActivation } from '../net/types.js';
export interface LayoutPickerProps { export interface LayoutPickerProps {
/** Currently selected layout. Parents own the state. */ /** Currently selected layout. Parents own the state. */
value: StartingLayout; value: StartingLayout;
/** Called when the user picks a new premade. */ /** Called when the user picks a new premade. */
onChange: (layout: StartingLayout) => void; 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. */ /** Called when the user picks the "Custom…" entry. */
onCustomRequested?: () => void; onCustomRequested?: () => void;
/** Disables the control (during network requests, etc). */ /** Disables the control (during network requests, etc). */
@ -40,6 +46,8 @@ export interface LayoutPickerProps {
export function LayoutPicker({ export function LayoutPicker({
value, value,
onChange, onChange,
activations = [],
setPresets,
onCustomRequested, onCustomRequested,
disabled = false, disabled = false,
}: LayoutPickerProps) { }: 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 ( return (
<div className="space-y-2"> <div className="space-y-2">
<label className="block text-xs font-bold text-neutral-500 uppercase tracking-widest"> <label className="block text-xs font-bold text-neutral-500 uppercase tracking-widest">
@ -124,9 +142,31 @@ export function LayoutPicker({
</p> </p>
{value.suggestedPresets !== undefined && {value.suggestedPresets !== undefined &&
value.suggestedPresets.length > 0 && ( value.suggestedPresets.length > 0 && (
<p className="text-xs text-neutral-400 italic"> <div className="pt-2 flex flex-wrap items-center gap-1.5">
Suggested rules: {value.suggestedPresets.join(', ')} <span className="text-xs text-neutral-400 italic mr-1">Suggested rules:</span>
</p> {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> </div>
); );

View file

@ -12,6 +12,7 @@ import {
validateLayout, validateLayout,
type StartingLayout, type StartingLayout,
} from '@paratype/chess'; } from '@paratype/chess';
import type { PresetActivation } from '../net/types.js';
import type { LayoutRequest } from '../net/types'; import type { LayoutRequest } from '../net/types';
import { LayoutPicker } from './LayoutPicker'; import { LayoutPicker } from './LayoutPicker';
import { LayoutEditor } from './LayoutEditor'; import { LayoutEditor } from './LayoutEditor';
@ -90,6 +91,9 @@ export function Lobby({ chessState }: LobbyProps = {}) {
* wire — the server protocol carries one ModifierProfile per room * wire — the server protocol carries one ModifierProfile per room
* (T2). Multi-stack on the wire is a future T27+ extension. * (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< const [additionalProfiles, setAdditionalProfiles] = useState<
ModifierProfile[] ModifierProfile[]
>([]); >([]);
@ -301,6 +305,9 @@ export function Lobby({ chessState }: LobbyProps = {}) {
if (selectedProfile !== null) { if (selectedProfile !== null) {
createPayload.profile = selectedProfile; createPayload.profile = selectedProfile;
} }
if (presets.length > 0) {
createPayload.rulesetIds = presets.map(p => p.id);
}
const { code, token, color, layout: resolvedLayout, profile: echoedProfile } = const { code, token, color, layout: resolvedLayout, profile: echoedProfile } =
await oneShotRoomRequest('room.create', createPayload); await oneShotRoomRequest('room.create', createPayload);
@ -428,6 +435,8 @@ export function Lobby({ chessState }: LobbyProps = {}) {
<LayoutPicker <LayoutPicker
value={selectedLayout} value={selectedLayout}
onChange={setSelectedLayout} onChange={setSelectedLayout}
activations={presets}
setPresets={setPresets}
onCustomRequested={() => setEditorOpen(true)} onCustomRequested={() => setEditorOpen(true)}
disabled={loading} disabled={loading}
/> />

View file

@ -223,7 +223,7 @@ export function RulesDrawer({
data-testid="rules-drawer" 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" 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> <div>
<h2 className="text-xl font-bold tracking-tight text-neutral-900"> <h2 className="text-xl font-bold tracking-tight text-neutral-900">
Live Rules Live Rules
@ -243,8 +243,25 @@ export function RulesDrawer({
</button> </button>
</header> </header>
<div className="flex-1 overflow-y-auto overscroll-contain p-6 space-y-4"> <div className="flex-1 overflow-y-auto overscroll-contain p-6">
{presets.map((preset) => { {['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 active = activeById.get(preset.id);
const isOn = active !== undefined; const isOn = active !== undefined;
@ -507,8 +524,12 @@ export function RulesDrawer({
); );
})} })}
</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 <button
type="button" type="button"
data-testid="open-modifier-editor" data-testid="open-modifier-editor"