diff --git a/packages/chess/e2e/rule-variants.spec.ts b/packages/chess/e2e/rule-variants.spec.ts index 6aa88c0..31efe23 100644 --- a/packages/chess/e2e/rule-variants.spec.ts +++ b/packages/chess/e2e/rule-variants.spec.ts @@ -324,3 +324,78 @@ test.describe('Rule variants — lobby vertical slice', () => { if (errors.length > 0) throw new Error(errors.join('; ')); }); }); + +test.describe('F4c post-epic: transferable-royalty UI', () => { + test('transfer royalty action popover and selection flow', async ({ page }) => { + await page.goto('/'); + + // Open rules drawer + await page.locator('[data-action="play-solo"]').click(); + await page.waitForURL('**/game'); + + // Open the drawer + await page.locator('[data-action="open-rules-drawer"]').click(); + await expect(page.locator('[data-testid="rules-drawer"]')).toBeVisible(); + + // Toggle Transferable Royalty ON + const trToggle = page.locator('[data-preset="transferable-royalty"] [data-role="toggle"]'); + await expect(trToggle).toBeVisible(); + await trToggle.click(); + await page.waitForTimeout(120); + + // Close drawer + await page.locator('[data-action="close-rules-drawer"]').click(); + await expect(page.locator('[data-testid="rules-drawer"]')).not.toBeVisible(); + + // Ensure game has started and is on white's turn + const turnIndicator = page.getByTestId('turn-indicator'); + await expect(turnIndicator).toHaveText("White's turn"); + + // 3. Assert "Actions" button is visible + const actionsMenuBtn = page.getByTestId('actions-menu-button'); + await expect(actionsMenuBtn).toBeVisible(); + + // 4. Click "Actions" -> popover opens + await actionsMenuBtn.click(); + + // 5. Click "Transfer royalty..." item + const transferBtn = page.getByTestId('action-transfer-royalty'); + await expect(transferBtn).toBeVisible(); + await transferBtn.click(); + + // 6. Hint card appears: "Select the piece whose royalty you want to transfer." + await expect(page.getByText('Select the piece whose royalty you want to transfer.')).toBeVisible(); + + // 7. Click the white king (e1). + await page.locator('[data-square="e1"]').click(); + + // 8. Hint updates: "Select the new royal piece." + await expect(page.getByText('Select the new royal piece.')).toBeVisible(); + + // 9. Click a friendly piece (e.g., the queen at d1) + await page.locator('[data-square="d1"]').click(); + + // 10. Board state updates: Turn flipped (meaning the action was accepted and consumed the turn) + await expect(turnIndicator).toHaveText("Black's turn"); + + // 11. "Transfer royalty..." is now greyed out (already used this game by White) + // Black's turn: click actions -> should be enabled for Black since Black hasn't used it. + await actionsMenuBtn.click(); + const transferBtn2 = page.getByTestId('action-transfer-royalty'); + await expect(transferBtn2).toBeVisible(); + await expect(transferBtn2).toBeEnabled(); + + // Play a move for Black to get back to White + await drag(page, 'e7', 'e5'); + + await expect(turnIndicator).toHaveText("White's turn"); + + // Now White's action should be disabled + await actionsMenuBtn.click(); + const transferBtn3 = page.getByTestId('action-transfer-royalty'); + await expect(transferBtn3).toBeVisible(); + await expect(transferBtn3).toBeDisabled(); + await expect(page.getByText('Used this game')).toBeVisible(); + }); +}); + diff --git a/packages/chess/src/ui/ActionMenu.tsx b/packages/chess/src/ui/ActionMenu.tsx new file mode 100644 index 0000000..19e39c5 --- /dev/null +++ b/packages/chess/src/ui/ActionMenu.tsx @@ -0,0 +1,99 @@ +import { useState, useRef, useEffect } from 'react'; +import { motion, AnimatePresence } from 'motion/react'; + +export interface ActionMenuProps { + /** True if the user can act right now (it's their turn) */ + isActive: boolean; + /** True if the transferable-royalty preset is active */ + canTransferRoyalty: boolean; + /** True if the user has NOT transferred royalty yet */ + hasTransferRemaining: boolean; + /** Call when the "Transfer royalty..." item is clicked */ + onTransferRoyaltyClick: () => void; +} + +export function ActionMenu({ + isActive, + canTransferRoyalty, + hasTransferRemaining, + onTransferRoyaltyClick, +}: ActionMenuProps) { + const [isOpen, setIsOpen] = useState(false); + const menuRef = useRef(null); + + // Close when clicking outside + useEffect(() => { + if (!isOpen) return; + const handleDocumentClick = (e: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(e.target as Node)) { + setIsOpen(false); + } + }; + // Use capture to avoid conflicts with buttons that trigger state changes + document.addEventListener('click', handleDocumentClick, true); + return () => document.removeEventListener('click', handleDocumentClick, true); + }, [isOpen]); + + // Don't render anything if no action-providing presets are active + if (!canTransferRoyalty) { + return null; + } + + return ( +
+ + + + {isOpen && ( + +
+ {canTransferRoyalty && ( + + )} +
+
+ )} +
+
+ ); +} diff --git a/packages/chess/src/ui/Board.tsx b/packages/chess/src/ui/Board.tsx index 3ec312b..d6c9c3c 100644 --- a/packages/chess/src/ui/Board.tsx +++ b/packages/chess/src/ui/Board.tsx @@ -45,6 +45,10 @@ interface BoardProps { * to pin the modifier inspection panel for that piece. */ onPieceClick?: (pieceId: number) => void; + /** + * Set of entity IDs to highlight (e.g. for action target selection). + */ + highlightedPieces?: Set; } interface PieceState { @@ -53,7 +57,7 @@ interface PieceState { color: PieceColor; } -export function Board({ facts, legalMoves, onMove, turn, myColor, lastMove, checkedKingSquare, activePresetIds, engine, onPieceHover, onPieceClick }: BoardProps) { +export function Board({ facts, legalMoves, onMove, turn, myColor, lastMove, checkedKingSquare, activePresetIds, engine, onPieceHover, onPieceClick, highlightedPieces }: BoardProps) { // Pre-compute overlay components once per render — lookup is cheap // but doing it once in a useMemo keeps the Piece render path clean. const overlays: PieceOverlayComponent[] = useMemo( @@ -249,6 +253,10 @@ export function Board({ facts, legalMoves, onMove, turn, myColor, lastMove, chec
)} + {highlightedPieces?.has(piece?.id as number) && ( +
+ )} + {/* Per-square preset overlays (poison tint, etc.). Each is a pure function of the square index; overlays decide for themselves whether to render on any given cell. */} diff --git a/packages/chess/src/ui/GameView.tsx b/packages/chess/src/ui/GameView.tsx index 25b054f..6a46f13 100644 --- a/packages/chess/src/ui/GameView.tsx +++ b/packages/chess/src/ui/GameView.tsx @@ -3,15 +3,18 @@ import { RulesDrawer } from './RulesDrawer'; import { ModifierTooltip } from './ModifierTooltip.js'; import { ModifierPinnedPanel } from './ModifierPinnedPanel.js'; import { ModifierProposalDialog } from './ModifierProposalDialog.js'; +import { ActionMenu } from './ActionMenu.js'; import { useChessEngine } from '../hooks/useChessEngine'; import { useMultiplayerGame } from '../hooks/useMultiplayerGame'; import type { ChessFact, ChessAttrMap, PieceType } from '../schema'; -import type { Color, PresetActivation, ModifierProfileWire } from '../net/types'; +import type { Color, PresetActivation, ModifierProfileWire, PlayerActionWire } from '../net/types'; import type { GameResult } from '../engine'; import type { LegalMove } from '../rules/types'; import type { ChessEngine } from '../engine'; +import type { EntityId } from '@paratype/rete'; +import { TRANSFERABLE_ROYALTY_ID } from '../presets/transferable-royalty.js'; import { isInCheck } from '../rules/check'; -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useRef, useState, useMemo } from 'react'; import confetti from 'canvas-confetti'; import { motion, AnimatePresence } from 'motion/react'; import { Volume2, VolumeX, Copy, Check } from 'lucide-react'; @@ -49,6 +52,7 @@ interface GameEngineState { sendRegisterCustomModifier?: ( descriptor: import('../modifiers/custom/types.js').CustomModifierDescriptor, ) => void; + sendAction?: (action: PlayerActionWire) => void; } interface GameViewProps { @@ -156,6 +160,7 @@ function GameLayout({ isProposer, sendConsent, sendRegisterCustomModifier, + sendAction, } = state as GameEngineState & { modifierProposal?: { profile: ModifierProfileWire; expiresAt: number; proposer: Color } | null; modifierRejectionMessage?: string | null; @@ -164,8 +169,29 @@ function GameLayout({ sendRegisterCustomModifier?: ( descriptor: import('../modifiers/custom/types.js').CustomModifierDescriptor, ) => void; + sendAction?: (action: PlayerActionWire) => void; }; + // Action Mode State + type ActionMode = + | { kind: "none" } + | { kind: "transfer-royalty-pick-from" } + | { kind: "transfer-royalty-pick-to"; fromPieceId: EntityId }; + + const [actionMode, setActionMode] = useState({ kind: "none" }); + + // Escape to cancel action mode + useEffect(() => { + if (actionMode.kind === "none") return; + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") { + setActionMode({ kind: "none" }); + } + }; + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [actionMode.kind]); + const handleMove = (from: number, to: number, promoteTo?: PieceType) => { applyMove(from, to, promoteTo || 'queen'); }; @@ -297,6 +323,117 @@ function GameLayout({ return null; })(); + // --- Actions Support --- + const canTransferRoyalty = activations.some(a => a.id === TRANSFERABLE_ROYALTY_ID); + + // Calculate candidates for transfer-royalty if in that mode + const actionHighlightCandidates = useMemo(() => { + if (engine === null || actionMode.kind === "none") return undefined; + + const highlighted = new Set(); + + if (actionMode.kind === "transfer-royalty-pick-from") { + // Pick FROM: must be royal piece for current mover + const royals = engine.getActiveRoyalEntityIds(turnAsColor); + if (royals) { + for (const id of royals) { + // Check if piece has Position (alive) + if (engine.session.contains(id, "Position")) { + highlighted.add(id as number); + } + } + } else { + // Fallback default kings + for (const f of facts) { + if (f.attr === "PieceType" && f.value === "king") { + const colorFact = facts.find(cf => cf.id === f.id && cf.attr === "Color"); + const posFact = facts.find(pf => pf.id === f.id && pf.attr === "Position"); + if (colorFact?.value === turnAsColor && posFact) { + highlighted.add(f.id as number); + } + } + } + } + } else if (actionMode.kind === "transfer-royalty-pick-to") { + // Pick TO: must be friendly non-royal live piece + const royals = new Set(engine.getActiveRoyalEntityIds(turnAsColor) ?? []); + if (royals.size === 0) { + for (const f of facts) { + if (f.attr === "PieceType" && f.value === "king") { + const colorFact = facts.find(cf => cf.id === f.id && cf.attr === "Color"); + if (colorFact?.value === turnAsColor) { + royals.add(f.id as EntityId); + } + } + } + } + + const myPieceIds = new Set(); + for (const f of facts) { + if (f.attr === "Color" && f.value === turnAsColor) { + const posFact = facts.find(pf => pf.id === f.id && pf.attr === "Position"); + if (posFact && !royals.has(f.id as EntityId)) { + myPieceIds.add(f.id as number); + } + } + } + for (const id of myPieceIds) highlighted.add(id); + } + + return highlighted; + }, [engine, actionMode, turnAsColor, facts]); + + const hasTransferRemaining = useMemo(() => { + if (!canTransferRoyalty || !engine) return false; + const st = engine.presetState(TRANSFERABLE_ROYALTY_ID); + return !st.has(`transferredFrom:${turnAsColor}`); + }, [canTransferRoyalty, engine, turnAsColor, state.turn]); // Use state.turn to refresh or we could expose tick from hook. We'll use result/turn as proxies for tick for now. + + const handlePieceClick = (pieceId: number) => { + if (actionMode.kind === "none") { + // Normal click -> pin modifier + setPinnedPieceId((prev) => (prev === pieceId ? null : pieceId)); + return; + } + + if (engine === null) return; + + if (actionMode.kind === "transfer-royalty-pick-from") { + if (actionHighlightCandidates?.has(pieceId as number)) { + setActionMode({ kind: "transfer-royalty-pick-to", fromPieceId: pieceId as EntityId }); + } else { + toast.error("Please select a valid royal piece."); + } + } else if (actionMode.kind === "transfer-royalty-pick-to") { + if (actionHighlightCandidates?.has(pieceId as number)) { + const action: PlayerActionWire = { + kind: "transfer-royalty", + fromPieceId: actionMode.fromPieceId as number, + toPieceId: pieceId as number + }; + if (roomCode === null) { + // Solo mode + const result = engine.performAction({ + kind: "transfer-royalty", + fromPieceId: actionMode.fromPieceId, + toPieceId: pieceId as EntityId + }); + if (result && result.ok) { + refresh(); + } else if (result) { + toast.error(result.reason || "Action failed"); + } + } else { + // Multiplayer mode + sendAction?.(action); + } + setActionMode({ kind: "none" }); + } else { + toast.error("Please select a friendly non-royal piece."); + } + } + }; + return ( } + + setActionMode({ kind: "transfer-royalty-pick-from" })} + />
@@ -471,9 +615,32 @@ function GameLayout({
+ + {actionMode.kind !== "none" && ( + +
+ {actionMode.kind === "transfer-royalty-pick-from" + ? "Select the piece whose royalty you want to transfer." + : "Select the new royal piece."} + + + )} + + []} - legalMoves={legalMoves} + legalMoves={actionMode.kind === "none" ? legalMoves : []} turn={turn as Color} myColor={myColor} onMove={handleMove} @@ -481,7 +648,8 @@ function GameLayout({ checkedKingSquare={checkedKingSquare} activePresetIds={activations.map((a) => a.id)} onPieceHover={handlePieceHover} - onPieceClick={(id) => setPinnedPieceId((prev) => (prev === id ? null : id))} + onPieceClick={handlePieceClick} + {...(actionHighlightCandidates ? { highlightedPieces: actionHighlightCandidates } : {})} {...(state.engine !== null ? { engine: state.engine } : {})} />