feat(ui): Actions menu + royalty-transfer target selection in GameView

This commit is contained in:
Joey Yakimowich-Payne 2026-04-21 12:04:55 -06:00
commit d6bc1ca2cc
No known key found for this signature in database
4 changed files with 355 additions and 5 deletions

View file

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

View file

@ -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<HTMLDivElement>(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 (
<div className="relative" ref={menuRef}>
<button
data-testid="actions-menu-button"
onClick={() => setIsOpen((prev) => !prev)}
disabled={!isActive}
className={`px-4 py-2 font-medium rounded-md border shadow-sm transition-colors focus:outline-none focus:ring-2 focus:ring-neutral-200
${
isActive
? isOpen
? 'bg-neutral-100 border-neutral-300 text-neutral-800'
: 'bg-white border-neutral-300 text-neutral-700 hover:bg-neutral-50 active:bg-neutral-100'
: 'bg-white border-neutral-200 text-neutral-400 opacity-60 cursor-not-allowed'
}
`}
>
Actions
</button>
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ opacity: 0, scale: 0.95, y: -5 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: -5 }}
transition={{ duration: 0.15, ease: 'easeOut' }}
className="absolute top-full right-0 mt-2 w-56 bg-neutral-100 border border-neutral-200 rounded-lg shadow-lg z-50 overflow-hidden"
>
<div className="py-1">
{canTransferRoyalty && (
<button
data-testid="action-transfer-royalty"
disabled={!hasTransferRemaining}
onClick={() => {
setIsOpen(false);
onTransferRoyaltyClick();
}}
className={`w-full text-left px-4 py-2 text-sm transition-colors
${
hasTransferRemaining
? 'text-neutral-700 hover:bg-neutral-200 active:bg-neutral-300'
: 'text-neutral-400 cursor-not-allowed opacity-60'
}
`}
>
<div className="font-medium">Transfer royalty</div>
{!hasTransferRemaining && (
<div className="text-xs mt-0.5">Used this game</div>
)}
</button>
)}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}

View file

@ -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<number>;
}
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
<div className="absolute inset-0 bg-yellow-400/30 pointer-events-none z-0" />
)}
{highlightedPieces?.has(piece?.id as number) && (
<div className="absolute inset-0 ring-4 ring-inset ring-amber-400 bg-amber-400/20 pointer-events-none z-10 shadow-[0_0_15px_rgba(251,191,36,0.6)]" />
)}
{/* 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. */}

View file

@ -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<ActionMode>({ 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<number>();
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<number>();
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 (
<motion.div
initial={{ opacity: 0, y: 20 }}
@ -352,6 +489,13 @@ function GameLayout({
{roomCode !== null && <RoomShareBadge code={roomCode} />}
<LayoutBadge />
<ModifierProfileBadge />
<ActionMenu
isActive={myColor !== null ? turn === myColor : true}
canTransferRoyalty={canTransferRoyalty}
hasTransferRemaining={hasTransferRemaining}
onTransferRoyaltyClick={() => setActionMode({ kind: "transfer-royalty-pick-from" })}
/>
</div>
<div className="flex items-center gap-3">
@ -471,9 +615,32 @@ function GameLayout({
</div>
<div className="w-full px-4 relative">
<AnimatePresence>
{actionMode.kind !== "none" && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
className="absolute -top-12 left-1/2 -translate-x-1/2 z-40 bg-white border border-neutral-200 shadow-lg px-4 py-2 rounded-full text-sm font-medium text-neutral-800 flex items-center gap-2"
>
<div className="w-2 h-2 rounded-full bg-amber-400 animate-pulse" />
{actionMode.kind === "transfer-royalty-pick-from"
? "Select the piece whose royalty you want to transfer."
: "Select the new royal piece."}
<button
onClick={() => setActionMode({ kind: "none" })}
className="ml-2 text-neutral-400 hover:text-neutral-600 transition-colors"
title="Cancel (Esc)"
>
</button>
</motion.div>
)}
</AnimatePresence>
<Board
facts={facts as ChessFact<keyof ChessAttrMap>[]}
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 } : {})}
/>