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({
|
PRESET_REGISTRY.register({
|
||||||
|
category: "movement",
|
||||||
id: PRESET_ID,
|
id: PRESET_ID,
|
||||||
name: "Berolina Pawns (Extended)",
|
name: "Berolina Pawns (Extended)",
|
||||||
description:
|
description:
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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.",
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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.",
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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.",
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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.",
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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. */
|
||||||
|
|
|
||||||
|
|
@ -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.",
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue