feat(ui): Actions menu + royalty-transfer target selection in GameView
This commit is contained in:
parent
2951a2d547
commit
d6bc1ca2cc
4 changed files with 355 additions and 5 deletions
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
99
packages/chess/src/ui/ActionMenu.tsx
Normal file
99
packages/chess/src/ui/ActionMenu.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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. */}
|
||||
|
|
|
|||
|
|
@ -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 } : {})}
|
||||
/>
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue