use component hooks

This commit is contained in:
seavor 2026-04-20 07:38:28 -05:00
parent 515dff6d7b
commit 3aa8c654cc
81 changed files with 5203 additions and 3173 deletions

View file

@ -1,6 +1,3 @@
// eslint-disable-next-line
import React, { useMemo, useState } from 'react';
import { CardDTO } from '@app/services';
import Card from '../Card/Card';

View file

@ -1,11 +1,9 @@
import { useMemo } from 'react';
import { App, Data } from '@app/types';
import { GameSelectors, useAppSelector } from '@app/store';
import { useSettings } from '@app/hooks';
import CardSlot from '../CardSlot/CardSlot';
import { makeCardKey } from '../CardRegistry/CardRegistryContext';
import BattlefieldRow from './BattlefieldRow';
import { useBattlefield } from './useBattlefield';
import './Battlefield.css';
@ -21,13 +19,6 @@ export interface BattlefieldProps {
onCardDoubleClick?: (card: Data.ServerInfo_Card) => void;
}
const ROW_COUNT = 3;
function rowIndexFor(card: Data.ServerInfo_Card): number {
const y = card.y ?? 0;
return Math.max(0, Math.min(ROW_COUNT - 1, y));
}
function Battlefield({
gameId,
playerId,
@ -39,28 +30,7 @@ function Battlefield({
onCardContextMenu,
onCardDoubleClick,
}: BattlefieldProps) {
const cards = useAppSelector((state) =>
GameSelectors.getCards(state, gameId, playerId, App.ZoneName.TABLE),
);
const { value: settings } = useSettings();
const invertVerticalCoordinate = settings?.invertVerticalCoordinate ?? false;
// Mirrors desktop TableZone::isInverted() — XOR of per-player mirrored and
// the global invertVerticalCoordinate preference.
const isInverted = mirrored !== invertVerticalCoordinate;
const rows = useMemo<Data.ServerInfo_Card[][]>(() => {
const bucketed: Data.ServerInfo_Card[][] = Array.from({ length: ROW_COUNT }, () => []);
for (const card of cards) {
bucketed[rowIndexFor(card)].push(card);
}
for (const row of bucketed) {
row.sort((a, b) => (a.x ?? 0) - (b.x ?? 0));
}
return bucketed;
}, [cards]);
const rowOrder = isInverted ? [2, 1, 0] : [0, 1, 2];
const { rows, rowOrder, isInverted } = useBattlefield({ gameId, playerId, mirrored });
return (
<div className="battlefield" data-testid="battlefield">

View file

@ -0,0 +1,50 @@
import { useMemo } from 'react';
import { App, Data } from '@app/types';
import { GameSelectors, useAppSelector } from '@app/store';
import { useSettings } from '@app/hooks';
export interface Battlefield {
rows: Data.ServerInfo_Card[][];
rowOrder: number[];
isInverted: boolean;
}
export interface UseBattlefieldArgs {
gameId: number;
playerId: number;
mirrored: boolean;
}
const ROW_COUNT = 3;
function rowIndexFor(card: Data.ServerInfo_Card): number {
const y = card.y ?? 0;
return Math.max(0, Math.min(ROW_COUNT - 1, y));
}
export function useBattlefield({ gameId, playerId, mirrored }: UseBattlefieldArgs): Battlefield {
const cards = useAppSelector((state) =>
GameSelectors.getCards(state, gameId, playerId, App.ZoneName.TABLE),
);
const { value: settings } = useSettings();
const invertVerticalCoordinate = settings?.invertVerticalCoordinate ?? false;
// Mirrors desktop TableZone::isInverted() — XOR of per-player mirrored and
// the global invertVerticalCoordinate preference.
const isInverted = mirrored !== invertVerticalCoordinate;
const rows = useMemo<Data.ServerInfo_Card[][]>(() => {
const bucketed: Data.ServerInfo_Card[][] = Array.from({ length: ROW_COUNT }, () => []);
for (const card of cards) {
bucketed[rowIndexFor(card)].push(card);
}
for (const row of bucketed) {
row.sort((a, b) => (a.x ?? 0) - (b.x ?? 0));
}
return bucketed;
}, [cards]);
const rowOrder = isInverted ? [2, 1, 0] : [0, 1, 2];
return { rows, rowOrder, isInverted };
}

View file

@ -2,8 +2,9 @@ import Menu from '@mui/material/Menu';
import MenuItem from '@mui/material/MenuItem';
import Divider from '@mui/material/Divider';
import { useWebClient } from '@app/hooks';
import { App, Data } from '@app/types';
import { Data } from '@app/types';
import { useCardContextMenu } from './useCardContextMenu';
import './CardContextMenu.css';
@ -24,156 +25,33 @@ export interface CardContextMenuProps {
onRequestMoveToLibraryAt: () => void;
}
interface MoveTarget {
label: string;
zone: string;
x: number;
y: number;
}
function CardContextMenu(props: CardContextMenuProps) {
const { isOpen, anchorPosition, card, onClose } = props;
const {
ready,
isOwnedByLocal,
canAttach,
isAttached,
moveTargets,
handleFlip,
handleTapToggle,
handleFaceDownToggle,
handleDoesntUntapToggle,
handleSetPT,
handleSetAnnotation,
handleCardCounterDelta,
handleSetCardCounter,
handleDrawArrow,
handleAttach,
handleUnattach,
handleMove,
handleMoveToLibraryAt,
} = useCardContextMenu(props);
// Mirrors desktop's cockatrice/src/game/player/menu/move_menu.cpp:32-42 —
// six fixed targets plus one prompt ("Move to library at position…") for the
// 7-entry parity. Note that desktop's "Send to Table" label maps to our
// "Send to Battlefield" (same wire semantics: zone=table, x=0, y=0); the
// label diverges but the command is identical.
const MOVE_TARGETS: ReadonlyArray<MoveTarget> = [
{ label: 'Send to Hand', zone: App.ZoneName.HAND, x: -1, y: 0 },
{ label: 'Send to Battlefield', zone: App.ZoneName.TABLE, x: 0, y: 0 },
{ label: 'Send to Graveyard', zone: App.ZoneName.GRAVE, x: 0, y: 0 },
{ label: 'Send to Exile', zone: App.ZoneName.EXILE, x: 0, y: 0 },
{ label: 'Send to Library (top)', zone: App.ZoneName.DECK, x: 0, y: 0 },
{ label: 'Send to Library (bottom)', zone: App.ZoneName.DECK, x: -1, y: 0 },
];
function CardContextMenu({
isOpen,
anchorPosition,
gameId,
localPlayerId,
card,
ownerPlayerId,
sourceZone,
onClose,
onRequestSetPT,
onRequestSetAnnotation,
onRequestSetCounter,
onRequestDrawArrow,
onRequestAttach,
onRequestMoveToLibraryAt,
}: CardContextMenuProps) {
const webClient = useWebClient();
if (!card || ownerPlayerId == null || sourceZone == null || localPlayerId == null) {
if (!ready || !card) {
return null;
}
const game = webClient.request.game;
const zone = sourceZone;
const cardId = card.id;
const setAttr = (attribute: Data.CardAttribute, value: string) => {
game.setCardAttr(gameId, { zone, cardId, attribute, attrValue: value });
};
const handleFlip = () => {
// TODO(card-db): desktop's Player::actCardMenuFlip reads the card's stored
// P/T and forwards it so the revealed side shows the correct stats
// (cockatrice/src/game/player/player_actions.cpp:1805-1810). We can't
// do that without a card-database-by-name lookup, which isn't wired in
// the webclient yet. The server re-derives PT from the card DB for known
// names, so omitting `pt` is harmless for non-custom cards.
game.flipCard(gameId, { zone, cardId, faceDown: !card.faceDown });
onClose();
};
const handleTapToggle = () => {
setAttr(Data.CardAttribute.AttrTapped, card.tapped ? '0' : '1');
onClose();
};
const handleFaceDownToggle = () => {
setAttr(Data.CardAttribute.AttrFaceDown, card.faceDown ? '0' : '1');
onClose();
};
const handleDoesntUntapToggle = () => {
setAttr(Data.CardAttribute.AttrDoesntUntap, card.doesntUntap ? '0' : '1');
onClose();
};
const handleSetPT = () => {
onRequestSetPT();
onClose();
};
const handleSetAnnotation = () => {
onRequestSetAnnotation();
onClose();
};
const handleCardCounterDelta = (delta: number) => {
game.incCardCounter(gameId, {
zone,
cardId,
counterId: 0,
counterDelta: delta,
});
onClose();
};
const handleSetCardCounter = () => {
onRequestSetCounter();
onClose();
};
const handleDrawArrow = () => {
onRequestDrawArrow();
onClose();
};
const handleAttach = () => {
onRequestAttach();
onClose();
};
const handleUnattach = () => {
// Desktop's actUnattach sends only start_zone + card_id; the server uses
// proto2 presence (`has_target_player_id()`) to detect "detach". Setting
// targetPlayerId: -1 here would leave presence set and trip the attach
// code path server-side. MessageInitShape makes these fields optional,
// so omitting them produces an unset wire field.
game.attachCard(gameId, { startZone: zone, cardId });
onClose();
};
const isAttached = card.attachCardId >= 0;
// Desktop's actAttach is only available from a table card; other zones
// never expose the attach arrow.
const canAttach = sourceZone === App.ZoneName.TABLE;
// Mutating actions (tap, flip, counters, attrs, P/T, annotation, attach,
// move) require ownership of the card — matches desktop's
// `card_menu.cpp:151-161` which drops all mutators when the menu target
// isn't getLocalOrJudge()-modifiable. Read-only actions (Draw arrow)
// stay available for planning/communication.
const isOwnedByLocal = ownerPlayerId === localPlayerId;
const handleMove = (target: MoveTarget) => {
// targetPlayerId is the ACTING player (local), matching desktop's
// Player::actMoveCardTo* which uses playerInfo->getId().
game.moveCard(gameId, {
startPlayerId: ownerPlayerId,
startZone: sourceZone,
cardsToMove: { card: [{ cardId }] },
targetPlayerId: localPlayerId,
targetZone: target.zone,
x: target.x,
y: target.y,
isReversed: false,
});
onClose();
};
return (
<Menu
open={isOpen}
@ -212,17 +90,12 @@ function CardContextMenu({
{isOwnedByLocal && (
<>
<Divider />
{MOVE_TARGETS.map((t) => (
{moveTargets.map((t) => (
<MenuItem key={t.label} onClick={() => handleMove(t)}>
{t.label}
</MenuItem>
))}
<MenuItem
onClick={() => {
onRequestMoveToLibraryAt();
onClose();
}}
>
<MenuItem onClick={handleMoveToLibraryAt}>
Move to library at position
</MenuItem>
</>

View file

@ -0,0 +1,239 @@
import { useWebClient } from '@app/hooks';
import { App, Data } from '@app/types';
interface MoveTarget {
label: string;
zone: string;
x: number;
y: number;
}
// Mirrors desktop's cockatrice/src/game/player/menu/move_menu.cpp:32-42 —
// six fixed targets plus one prompt ("Move to library at position…") for the
// 7-entry parity. Note that desktop's "Send to Table" label maps to our
// "Send to Battlefield" (same wire semantics: zone=table, x=0, y=0); the
// label diverges but the command is identical.
export const CARD_MOVE_TARGETS: ReadonlyArray<MoveTarget> = [
{ label: 'Send to Hand', zone: App.ZoneName.HAND, x: -1, y: 0 },
{ label: 'Send to Battlefield', zone: App.ZoneName.TABLE, x: 0, y: 0 },
{ label: 'Send to Graveyard', zone: App.ZoneName.GRAVE, x: 0, y: 0 },
{ label: 'Send to Exile', zone: App.ZoneName.EXILE, x: 0, y: 0 },
{ label: 'Send to Library (top)', zone: App.ZoneName.DECK, x: 0, y: 0 },
{ label: 'Send to Library (bottom)', zone: App.ZoneName.DECK, x: -1, y: 0 },
];
export interface CardContextMenu {
ready: boolean;
isOwnedByLocal: boolean;
canAttach: boolean;
isAttached: boolean;
moveTargets: ReadonlyArray<MoveTarget>;
handleFlip: () => void;
handleTapToggle: () => void;
handleFaceDownToggle: () => void;
handleDoesntUntapToggle: () => void;
handleSetPT: () => void;
handleSetAnnotation: () => void;
handleCardCounterDelta: (delta: number) => void;
handleSetCardCounter: () => void;
handleDrawArrow: () => void;
handleAttach: () => void;
handleUnattach: () => void;
handleMove: (target: MoveTarget) => void;
handleMoveToLibraryAt: () => void;
}
export interface UseCardContextMenuArgs {
gameId: number;
localPlayerId: number | null;
card: Data.ServerInfo_Card | null;
ownerPlayerId: number | null;
sourceZone: string | null;
onClose: () => void;
onRequestSetPT: () => void;
onRequestSetAnnotation: () => void;
onRequestSetCounter: () => void;
onRequestDrawArrow: () => void;
onRequestAttach: () => void;
onRequestMoveToLibraryAt: () => void;
}
export function useCardContextMenu({
gameId,
localPlayerId,
card,
ownerPlayerId,
sourceZone,
onClose,
onRequestSetPT,
onRequestSetAnnotation,
onRequestSetCounter,
onRequestDrawArrow,
onRequestAttach,
onRequestMoveToLibraryAt,
}: UseCardContextMenuArgs): CardContextMenu {
const webClient = useWebClient();
const ready = card != null && ownerPlayerId != null && sourceZone != null && localPlayerId != null;
// Mutating actions (tap, flip, counters, attrs, P/T, annotation, attach,
// move) require ownership of the card — matches desktop's
// `card_menu.cpp:151-161` which drops all mutators when the menu target
// isn't getLocalOrJudge()-modifiable. Read-only actions (Draw arrow)
// stay available for planning/communication.
const isOwnedByLocal = ready && ownerPlayerId === localPlayerId;
const isAttached = ready && (card!.attachCardId ?? -1) >= 0;
// Desktop's actAttach is only available from a table card; other zones
// never expose the attach arrow.
const canAttach = ready && sourceZone === App.ZoneName.TABLE;
const setAttr = (attribute: Data.CardAttribute, value: string) => {
if (!ready) {
return;
}
webClient.request.game.setCardAttr(gameId, {
zone: sourceZone!,
cardId: card!.id,
attribute,
attrValue: value,
});
};
const handleFlip = () => {
if (!ready) {
return;
}
// TODO(card-db): desktop's Player::actCardMenuFlip reads the card's stored
// P/T and forwards it so the revealed side shows the correct stats
// (cockatrice/src/game/player/player_actions.cpp:1805-1810). We can't
// do that without a card-database-by-name lookup, which isn't wired in
// the webclient yet. The server re-derives PT from the card DB for known
// names, so omitting `pt` is harmless for non-custom cards.
webClient.request.game.flipCard(gameId, {
zone: sourceZone!,
cardId: card!.id,
faceDown: !card!.faceDown,
});
onClose();
};
const handleTapToggle = () => {
if (!ready) {
return;
}
setAttr(Data.CardAttribute.AttrTapped, card!.tapped ? '0' : '1');
onClose();
};
const handleFaceDownToggle = () => {
if (!ready) {
return;
}
setAttr(Data.CardAttribute.AttrFaceDown, card!.faceDown ? '0' : '1');
onClose();
};
const handleDoesntUntapToggle = () => {
if (!ready) {
return;
}
setAttr(Data.CardAttribute.AttrDoesntUntap, card!.doesntUntap ? '0' : '1');
onClose();
};
const handleSetPT = () => {
onRequestSetPT();
onClose();
};
const handleSetAnnotation = () => {
onRequestSetAnnotation();
onClose();
};
const handleCardCounterDelta = (delta: number) => {
if (!ready) {
return;
}
webClient.request.game.incCardCounter(gameId, {
zone: sourceZone!,
cardId: card!.id,
counterId: 0,
counterDelta: delta,
});
onClose();
};
const handleSetCardCounter = () => {
onRequestSetCounter();
onClose();
};
const handleDrawArrow = () => {
onRequestDrawArrow();
onClose();
};
const handleAttach = () => {
onRequestAttach();
onClose();
};
const handleUnattach = () => {
if (!ready) {
return;
}
// Desktop's actUnattach sends only start_zone + card_id; the server uses
// proto2 presence (`has_target_player_id()`) to detect "detach". Setting
// targetPlayerId: -1 here would leave presence set and trip the attach
// code path server-side. MessageInitShape makes these fields optional,
// so omitting them produces an unset wire field.
webClient.request.game.attachCard(gameId, { startZone: sourceZone!, cardId: card!.id });
onClose();
};
const handleMove = (target: MoveTarget) => {
if (!ready) {
return;
}
// targetPlayerId is the ACTING player (local), matching desktop's
// Player::actMoveCardTo* which uses playerInfo->getId().
webClient.request.game.moveCard(gameId, {
startPlayerId: ownerPlayerId!,
startZone: sourceZone!,
cardsToMove: { card: [{ cardId: card!.id }] },
targetPlayerId: localPlayerId!,
targetZone: target.zone,
x: target.x,
y: target.y,
isReversed: false,
});
onClose();
};
const handleMoveToLibraryAt = () => {
onRequestMoveToLibraryAt();
onClose();
};
return {
ready,
isOwnedByLocal,
canAttach,
isAttached,
moveTargets: CARD_MOVE_TARGETS,
handleFlip,
handleTapToggle,
handleFaceDownToggle,
handleDoesntUntapToggle,
handleSetPT,
handleSetAnnotation,
handleCardCounterDelta,
handleSetCardCounter,
handleDrawArrow,
handleAttach,
handleUnattach,
handleMove,
handleMoveToLibraryAt,
};
}

View file

@ -1,12 +1,7 @@
import { useCallback, useId } from 'react';
import { useDraggable, useDroppable } from '@dnd-kit/core';
import { useScryfallCard } from '@app/hooks';
import { App } from '@app/types';
import type { Data } from '@app/types';
import { cx } from '@app/utils';
import { makeCardKey, useRegisterCardRef } from '../CardRegistry/CardRegistryContext';
import { useCardSlot } from './useCardSlot';
import './CardSlot.css';
@ -38,55 +33,13 @@ function CardSlot({
onContextMenu,
onMouseEnter,
}: CardSlotProps) {
const { smallUrl } = useScryfallCard(card);
// React-stable id salts the dnd-kit IDs so even two disabled CardSlots
// rendering the same card (during state transitions / hidden-zone leaks)
// never collide. Without the salt, pre-owner/zone render cycles shared
// `card-x-x-<id>` and dnd-kit warned.
const instanceId = useId();
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
id: `card-${ownerPlayerId ?? instanceId}-${zone ?? 'x'}-${card.id}`,
data: { card, sourcePlayerId: ownerPlayerId, sourceZone: zone },
disabled: !draggable || ownerPlayerId == null || zone == null,
const { smallUrl, attributes, listeners, isDragging, isOver, rootRef } = useCardSlot({
card,
draggable,
ownerPlayerId,
zone,
});
// Cards on the battlefield double as drop targets for drag-to-attach.
// Other zones don't support attach (desktop's Player::actAttach rejects
// non-table targets), so the droppable is only live for TABLE.
const droppableEnabled =
ownerPlayerId != null && zone === App.ZoneName.TABLE;
const { setNodeRef: setDropRef, isOver } = useDroppable({
id: `card-drop-${ownerPlayerId ?? instanceId}-${zone ?? 'x'}-${card.id}`,
data: {
attachTarget: true,
targetPlayerId: ownerPlayerId,
targetZone: zone,
targetCardId: card.id,
},
disabled: !droppableEnabled,
});
const registryKey =
ownerPlayerId != null && zone != null
? makeCardKey(ownerPlayerId, zone, card.id)
: null;
const registerRef = useRegisterCardRef(registryKey);
const rootRef = useCallback(
(el: HTMLElement | null) => {
registerRef(el);
if (draggable) {
setNodeRef(el);
}
if (droppableEnabled) {
setDropRef(el);
}
},
[registerRef, setNodeRef, setDropRef, draggable, droppableEnabled],
);
const className = cx('card-slot', {
'card-slot--tapped': card.tapped,
'card-slot--inverted': inverted,

View file

@ -0,0 +1,89 @@
import { useCallback, useId } from 'react';
import {
useDraggable,
useDroppable,
type DraggableAttributes,
type DraggableSyntheticListeners,
} from '@dnd-kit/core';
import { useScryfallCard } from '@app/hooks';
import { App } from '@app/types';
import type { Data } from '@app/types';
import { makeCardKey, useRegisterCardRef } from '../CardRegistry/CardRegistryContext';
export interface CardSlot {
smallUrl: string | null | undefined;
attributes: DraggableAttributes;
listeners: DraggableSyntheticListeners;
isDragging: boolean;
isOver: boolean;
rootRef: (el: HTMLElement | null) => void;
}
export interface UseCardSlotArgs {
card: Data.ServerInfo_Card;
draggable: boolean;
ownerPlayerId: number | undefined;
zone: string | undefined;
}
export function useCardSlot({ card, draggable, ownerPlayerId, zone }: UseCardSlotArgs): CardSlot {
const { smallUrl } = useScryfallCard(card);
// React-stable id salts the dnd-kit IDs so even two disabled CardSlots
// rendering the same card (during state transitions / hidden-zone leaks)
// never collide. Without the salt, pre-owner/zone render cycles shared
// `card-x-x-<id>` and dnd-kit warned.
const instanceId = useId();
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
id: `card-${ownerPlayerId ?? instanceId}-${zone ?? 'x'}-${card.id}`,
data: { card, sourcePlayerId: ownerPlayerId, sourceZone: zone },
disabled: !draggable || ownerPlayerId == null || zone == null,
});
// Cards on the battlefield double as drop targets for drag-to-attach.
// Other zones don't support attach (desktop's Player::actAttach rejects
// non-table targets), so the droppable is only live for TABLE.
const droppableEnabled =
ownerPlayerId != null && zone === App.ZoneName.TABLE;
const { setNodeRef: setDropRef, isOver } = useDroppable({
id: `card-drop-${ownerPlayerId ?? instanceId}-${zone ?? 'x'}-${card.id}`,
data: {
attachTarget: true,
targetPlayerId: ownerPlayerId,
targetZone: zone,
targetCardId: card.id,
},
disabled: !droppableEnabled,
});
const registryKey =
ownerPlayerId != null && zone != null
? makeCardKey(ownerPlayerId, zone, card.id)
: null;
const registerRef = useRegisterCardRef(registryKey);
const rootRef = useCallback(
(el: HTMLElement | null) => {
registerRef(el);
if (draggable) {
setNodeRef(el);
}
if (droppableEnabled) {
setDropRef(el);
}
},
[registerRef, setNodeRef, setDropRef, draggable, droppableEnabled],
);
return {
smallUrl,
attributes,
listeners,
isDragging,
isOver,
rootRef,
};
}

View file

@ -1,11 +1,4 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useState } from 'react';
import { useWebClient } from '@app/hooks';
import { GameSelectors, useAppSelector } from '@app/store';
import { App } from '@app/types';
import type { Data, Enriched } from '@app/types';
import { makeCardKey, useCardRegistry } from '../CardRegistry/CardRegistryContext';
import { useGameArrowOverlay } from './useGameArrowOverlay';
import './GameArrowOverlay.css';
@ -15,108 +8,8 @@ export interface GameArrowOverlayProps {
dragPreview?: { x1: number; y1: number; x2: number; y2: number; color: string } | null;
}
interface ResolvedArrow {
arrowId: number;
ownerPlayerId: number;
x1: number;
y1: number;
x2: number;
y2: number;
color: string;
}
const ARROW_FALLBACK_CSS = App.rgbaToCss(App.ArrowColor.RED);
function cssColor(c: { r: number; g: number; b: number; a: number } | undefined): string {
if (!c) {
return ARROW_FALLBACK_CSS;
}
return App.rgbaToCss({ r: c.r, g: c.g, b: c.b, a: c.a ?? 255 });
}
function GameArrowOverlay({ gameId, boardRef, dragPreview = null }: GameArrowOverlayProps) {
const webClient = useWebClient();
const registry = useCardRegistry();
const players = useAppSelector((state) =>
gameId != null ? GameSelectors.getPlayers(state, gameId) : undefined,
);
// Tick is bumped whenever we need to re-query DOM rects (card registry
// mutation, board resize). Keeps the overlay declarative without an external
// layout engine.
const [tick, setTick] = useState(0);
const bump = useCallback(() => {
setTick((t) => t + 1);
}, []);
useEffect(() => {
if (!registry) {
return undefined;
}
return registry.subscribe(bump);
}, [registry, bump]);
// First-paint: the board ref is null during the initial render, so `boardRect`
// is undefined and the arrows memo bails out. Bump once after mount so the
// next render sees a populated ref.
useLayoutEffect(() => {
bump();
}, [bump]);
useLayoutEffect(() => {
const el = boardRef.current;
if (!el || typeof ResizeObserver === 'undefined') {
return undefined;
}
const ro = new ResizeObserver(() => bump());
ro.observe(el);
return () => ro.disconnect();
}, [boardRef, bump]);
const boardRect = boardRef.current?.getBoundingClientRect();
const arrows = useMemo<ResolvedArrow[]>(() => {
if (!players || !registry || !boardRect) {
return [];
}
const out: ResolvedArrow[] = [];
for (const player of Object.values(players) as Enriched.PlayerEntry[]) {
for (const a of Object.values(player.arrows) as Data.ServerInfo_Arrow[]) {
const sourceEl = registry.get(
makeCardKey(a.startPlayerId, a.startZone, a.startCardId),
);
const targetEl = registry.get(
makeCardKey(a.targetPlayerId, a.targetZone, a.targetCardId),
);
if (!sourceEl || !targetEl) {
continue;
}
const s = sourceEl.getBoundingClientRect();
const t = targetEl.getBoundingClientRect();
out.push({
arrowId: a.id,
ownerPlayerId: player.properties.playerId,
x1: s.left + s.width / 2 - boardRect.left,
y1: s.top + s.height / 2 - boardRect.top,
x2: t.left + t.width / 2 - boardRect.left,
y2: t.top + t.height / 2 - boardRect.top,
color: cssColor(a.arrowColor),
});
}
}
// `tick` in deps intentionally re-runs the memo on DOM-layout changes.
return out;
}, [players, registry, boardRect, tick]);
const handleArrowClick = (arrowId: number) => {
if (gameId == null) {
return;
}
webClient.request.game.deleteArrow(gameId, { arrowId });
};
const width = boardRect?.width ?? 0;
const height = boardRect?.height ?? 0;
const { arrows, width, height, handleArrowClick } = useGameArrowOverlay({ gameId, boardRef });
return (
<svg

View file

@ -0,0 +1,129 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useState } from 'react';
import { useWebClient } from '@app/hooks';
import { GameSelectors, useAppSelector } from '@app/store';
import { App } from '@app/types';
import type { Data, Enriched } from '@app/types';
import { makeCardKey, useCardRegistry } from '../CardRegistry/CardRegistryContext';
export interface ResolvedArrow {
arrowId: number;
ownerPlayerId: number;
x1: number;
y1: number;
x2: number;
y2: number;
color: string;
}
const ARROW_FALLBACK_CSS = App.rgbaToCss(App.ArrowColor.RED);
function cssColor(c: { r: number; g: number; b: number; a: number } | undefined): string {
if (!c) {
return ARROW_FALLBACK_CSS;
}
return App.rgbaToCss({ r: c.r, g: c.g, b: c.b, a: c.a ?? 255 });
}
export interface GameArrowOverlay {
arrows: ResolvedArrow[];
width: number;
height: number;
handleArrowClick: (arrowId: number) => void;
}
export interface UseGameArrowOverlayArgs {
gameId: number | undefined;
boardRef: React.RefObject<HTMLElement | null>;
}
export function useGameArrowOverlay({
gameId,
boardRef,
}: UseGameArrowOverlayArgs): GameArrowOverlay {
const webClient = useWebClient();
const registry = useCardRegistry();
const players = useAppSelector((state) =>
gameId != null ? GameSelectors.getPlayers(state, gameId) : undefined,
);
// Tick is bumped whenever we need to re-query DOM rects (card registry
// mutation, board resize). Keeps the overlay declarative without an external
// layout engine.
const [tick, setTick] = useState(0);
const bump = useCallback(() => {
setTick((t) => t + 1);
}, []);
useEffect(() => {
if (!registry) {
return undefined;
}
return registry.subscribe(bump);
}, [registry, bump]);
// First-paint: the board ref is null during the initial render, so `boardRect`
// is undefined and the arrows memo bails out. Bump once after mount so the
// next render sees a populated ref.
useLayoutEffect(() => {
bump();
}, [bump]);
useLayoutEffect(() => {
const el = boardRef.current;
if (!el || typeof ResizeObserver === 'undefined') {
return undefined;
}
const ro = new ResizeObserver(() => bump());
ro.observe(el);
return () => ro.disconnect();
}, [boardRef, bump]);
const boardRect = boardRef.current?.getBoundingClientRect();
const arrows = useMemo<ResolvedArrow[]>(() => {
if (!players || !registry || !boardRect) {
return [];
}
const out: ResolvedArrow[] = [];
for (const player of Object.values(players) as Enriched.PlayerEntry[]) {
for (const a of Object.values(player.arrows) as Data.ServerInfo_Arrow[]) {
const sourceEl = registry.get(
makeCardKey(a.startPlayerId, a.startZone, a.startCardId),
);
const targetEl = registry.get(
makeCardKey(a.targetPlayerId, a.targetZone, a.targetCardId),
);
if (!sourceEl || !targetEl) {
continue;
}
const s = sourceEl.getBoundingClientRect();
const t = targetEl.getBoundingClientRect();
out.push({
arrowId: a.id,
ownerPlayerId: player.properties.playerId,
x1: s.left + s.width / 2 - boardRect.left,
y1: s.top + s.height / 2 - boardRect.top,
x2: t.left + t.width / 2 - boardRect.left,
y2: t.top + t.height / 2 - boardRect.top,
color: cssColor(a.arrowColor),
});
}
}
// `tick` in deps intentionally re-runs the memo on DOM-layout changes.
return out;
}, [players, registry, boardRect, tick]);
const handleArrowClick = (arrowId: number) => {
if (gameId == null) {
return;
}
webClient.request.game.deleteArrow(gameId, { arrowId });
};
const width = boardRect?.width ?? 0;
const height = boardRect?.height ?? 0;
return { arrows, width, height, handleArrowClick };
}

View file

@ -1,92 +1,24 @@
import { useEffect, useRef, useState } from 'react';
import { useWebClient } from '@app/hooks';
import { GameSelectors, useAppSelector } from '@app/store';
import type { Enriched } from '@app/types';
import { useRef } from 'react';
import { formatElapsed, useGameLog } from './useGameLog';
import './GameLog.css';
const EMPTY_MESSAGES: Enriched.GameMessage[] = [];
function formatElapsed(totalSeconds: number): string {
const s = Math.max(0, Math.floor(totalSeconds));
const hh = String(Math.floor(s / 3600)).padStart(2, '0');
const mm = String(Math.floor((s % 3600) / 60)).padStart(2, '0');
const ss = String(s % 60).padStart(2, '0');
return `${hh}:${mm}:${ss}`;
}
export interface GameLogProps {
gameId: number | undefined;
}
function GameLog({ gameId }: GameLogProps) {
const webClient = useWebClient();
const messages = useAppSelector((state) =>
gameId != null ? GameSelectors.getMessages(state, gameId) : EMPTY_MESSAGES,
);
const players = useAppSelector((state) =>
gameId != null ? GameSelectors.getPlayers(state, gameId) : undefined,
);
const secondsElapsed = useAppSelector((state) =>
gameId != null ? GameSelectors.getSecondsElapsed(state, gameId) : 0,
);
// Local 1Hz ticker, resynced from Redux whenever a server event delivers a
// fresh `secondsElapsed`. Mirrors desktop's QTimer(1000) +
// setGameTime(event.seconds_elapsed()) pattern in game_state.cpp.
const [displaySeconds, setDisplaySeconds] = useState(secondsElapsed);
useEffect(() => {
setDisplaySeconds(secondsElapsed);
}, [secondsElapsed]);
useEffect(() => {
if (gameId == null) {
return undefined;
}
const id = window.setInterval(() => {
setDisplaySeconds((prev) => prev + 1);
}, 1000);
return () => window.clearInterval(id);
}, [gameId]);
const [draft, setDraft] = useState('');
const listRef = useRef<HTMLDivElement>(null);
// Desktop pins the log to the bottom unless the user has scrolled up to read backlog.
// Capture pin state before the new line renders so auto-scroll only fires when the
// user was already following the tail.
const wasPinnedRef = useRef(true);
useEffect(() => {
const el = listRef.current;
if (!el) {
return;
}
if (wasPinnedRef.current) {
el.scrollTop = el.scrollHeight;
}
}, [messages.length]);
const handleMessagesScroll = () => {
const el = listRef.current;
if (!el) {
return;
}
wasPinnedRef.current = el.scrollTop + el.clientHeight >= el.scrollHeight - 2;
};
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (gameId == null) {
return;
}
const trimmed = draft.trim();
if (trimmed.length === 0) {
return;
}
webClient.request.game.gameSay(gameId, { message: trimmed });
setDraft('');
};
const {
messages,
players,
displaySeconds,
draft,
setDraft,
handleMessagesScroll,
handleSubmit,
} = useGameLog({ gameId, listRef });
return (
<div className="game-log" data-testid="game-log">

View file

@ -0,0 +1,111 @@
import { useEffect, useRef, useState, RefObject } from 'react';
import { useWebClient } from '@app/hooks';
import { GameSelectors, useAppSelector } from '@app/store';
import type { Enriched } from '@app/types';
const EMPTY_MESSAGES: Enriched.GameMessage[] = [];
export function formatElapsed(totalSeconds: number): string {
const s = Math.max(0, Math.floor(totalSeconds));
const hh = String(Math.floor(s / 3600)).padStart(2, '0');
const mm = String(Math.floor((s % 3600) / 60)).padStart(2, '0');
const ss = String(s % 60).padStart(2, '0');
return `${hh}:${mm}:${ss}`;
}
export interface GameLog {
messages: Enriched.GameMessage[];
players: Record<number, Enriched.PlayerEntry> | undefined;
displaySeconds: number;
draft: string;
setDraft: (v: string) => void;
handleMessagesScroll: () => void;
handleSubmit: (e: React.FormEvent<HTMLFormElement>) => void;
}
export interface UseGameLogArgs {
gameId: number | undefined;
listRef: RefObject<HTMLDivElement | null>;
}
export function useGameLog({ gameId, listRef }: UseGameLogArgs): GameLog {
const webClient = useWebClient();
// getMessages falls back to a shared EMPTY_ARRAY typed as ServerInfo_Card[]
// (see game.selectors.ts). The runtime array is empty, so the cast is safe;
// fixing the selector's fallback type is out of scope for this refactor.
const messages = useAppSelector((state) =>
gameId != null ? GameSelectors.getMessages(state, gameId) : EMPTY_MESSAGES,
) as Enriched.GameMessage[];
const players = useAppSelector((state) =>
gameId != null ? GameSelectors.getPlayers(state, gameId) : undefined,
);
const secondsElapsed = useAppSelector((state) =>
gameId != null ? GameSelectors.getSecondsElapsed(state, gameId) : 0,
);
// Local 1Hz ticker, resynced from Redux whenever a server event delivers a
// fresh `secondsElapsed`. Mirrors desktop's QTimer(1000) +
// setGameTime(event.seconds_elapsed()) pattern in game_state.cpp.
const [displaySeconds, setDisplaySeconds] = useState(secondsElapsed);
useEffect(() => {
setDisplaySeconds(secondsElapsed);
}, [secondsElapsed]);
useEffect(() => {
if (gameId == null) {
return undefined;
}
const id = window.setInterval(() => {
setDisplaySeconds((prev) => prev + 1);
}, 1000);
return () => window.clearInterval(id);
}, [gameId]);
const [draft, setDraft] = useState('');
// Desktop pins the log to the bottom unless the user has scrolled up to read backlog.
// Capture pin state before the new line renders so auto-scroll only fires when the
// user was already following the tail.
const wasPinnedRef = useRef(true);
useEffect(() => {
const el = listRef.current;
if (!el) {
return;
}
if (wasPinnedRef.current) {
el.scrollTop = el.scrollHeight;
}
}, [messages.length, listRef]);
const handleMessagesScroll = () => {
const el = listRef.current;
if (!el) {
return;
}
wasPinnedRef.current = el.scrollTop + el.clientHeight >= el.scrollHeight - 2;
};
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (gameId == null) {
return;
}
const trimmed = draft.trim();
if (trimmed.length === 0) {
return;
}
webClient.request.game.gameSay(gameId, { message: trimmed });
setDraft('');
};
return {
messages,
players,
displaySeconds,
draft,
setDraft,
handleMessagesScroll,
handleSubmit,
};
}

View file

@ -2,7 +2,7 @@ import Menu from '@mui/material/Menu';
import MenuItem from '@mui/material/MenuItem';
import Divider from '@mui/material/Divider';
import { useWebClient } from '@app/hooks';
import { useHandContextMenu } from './useHandContextMenu';
import './HandContextMenu.css';
@ -27,51 +27,15 @@ function HandContextMenu({
onRequestRevealHand,
onRequestRevealRandom,
}: HandContextMenuProps) {
const webClient = useWebClient();
const handleChoose = () => {
if (gameId <= 0) {
return;
}
onRequestChooseMulligan();
onClose();
};
const handleSameSize = () => {
if (gameId <= 0) {
return;
}
webClient.request.game.mulligan(gameId, { number: handSize });
onClose();
};
const handleMinusOne = () => {
if (gameId <= 0) {
return;
}
// Desktop's actMulliganMinusOne floors at 1 (see
// cockatrice/src/game/player/player_actions.cpp actMulliganMinusOne);
// the server-side doMulligan rejects number < 1.
const next = Math.max(1, handSize - 1);
webClient.request.game.mulligan(gameId, { number: next });
onClose();
};
const handleRevealHand = () => {
if (gameId <= 0) {
return;
}
onRequestRevealHand();
onClose();
};
const handleRevealRandom = () => {
if (gameId <= 0) {
return;
}
onRequestRevealRandom();
onClose();
};
const { handleChoose, handleSameSize, handleMinusOne, handleRevealHand, handleRevealRandom } =
useHandContextMenu({
gameId,
handSize,
onClose,
onRequestChooseMulligan,
onRequestRevealHand,
onRequestRevealRandom,
});
return (
<Menu

View file

@ -0,0 +1,75 @@
import { useWebClient } from '@app/hooks';
export interface HandContextMenu {
handleChoose: () => void;
handleSameSize: () => void;
handleMinusOne: () => void;
handleRevealHand: () => void;
handleRevealRandom: () => void;
}
export interface UseHandContextMenuArgs {
gameId: number;
handSize: number;
onClose: () => void;
onRequestChooseMulligan: () => void;
onRequestRevealHand: () => void;
onRequestRevealRandom: () => void;
}
export function useHandContextMenu({
gameId,
handSize,
onClose,
onRequestChooseMulligan,
onRequestRevealHand,
onRequestRevealRandom,
}: UseHandContextMenuArgs): HandContextMenu {
const webClient = useWebClient();
const handleChoose = () => {
if (gameId <= 0) {
return;
}
onRequestChooseMulligan();
onClose();
};
const handleSameSize = () => {
if (gameId <= 0) {
return;
}
webClient.request.game.mulligan(gameId, { number: handSize });
onClose();
};
const handleMinusOne = () => {
if (gameId <= 0) {
return;
}
// Desktop's actMulliganMinusOne floors at 1 (see
// cockatrice/src/game/player/player_actions.cpp actMulliganMinusOne);
// the server-side doMulligan rejects number < 1.
const next = Math.max(1, handSize - 1);
webClient.request.game.mulligan(gameId, { number: next });
onClose();
};
const handleRevealHand = () => {
if (gameId <= 0) {
return;
}
onRequestRevealHand();
onClose();
};
const handleRevealRandom = () => {
if (gameId <= 0) {
return;
}
onRequestRevealRandom();
onClose();
};
return { handleChoose, handleSameSize, handleMinusOne, handleRevealHand, handleRevealRandom };
}

View file

@ -1,10 +1,9 @@
import { useDroppable } from '@dnd-kit/core';
import { App, Data } from '@app/types';
import { GameSelectors, useAppSelector } from '@app/store';
import { cx } from '@app/utils';
import CardSlot from '../CardSlot/CardSlot';
import { makeCardKey } from '../CardRegistry/CardRegistryContext';
import { useHandZone } from './useHandZone';
import './HandZone.css';
@ -29,33 +28,13 @@ function HandZone({
onCardContextMenu,
onZoneContextMenu,
}: HandZoneProps) {
const cards = useAppSelector((state) =>
GameSelectors.getCards(state, gameId, playerId, App.ZoneName.HAND),
);
// Match desktop: can't drop into a hand zone that isn't yours (judges
// aside; server enforces the same restriction). Today only the local
// HandZone mounts, but this guard future-proofs opponent-hand mirrors.
const { setNodeRef, isOver } = useDroppable({
id: `hand-${playerId}`,
data: { targetPlayerId: playerId, targetZone: App.ZoneName.HAND },
disabled: !canAct,
const { cards, setNodeRef, isOver, handleZoneContextMenu } = useHandZone({
gameId,
playerId,
canAct,
onZoneContextMenu,
});
// Right-click anywhere inside the hand that doesn't land on a card opens
// the hand zone context menu (mulligan / reveal hand). Card-level right-
// click has its own handler on CardSlot.
const handleZoneContextMenu = (e: React.MouseEvent<HTMLDivElement>) => {
if (!onZoneContextMenu) {
return;
}
const target = e.target as HTMLElement;
if (target.closest('[data-card-id]')) {
return;
}
onZoneContextMenu(e);
};
return (
<div
ref={setNodeRef}

View file

@ -0,0 +1,55 @@
import { useDroppable } from '@dnd-kit/core';
import type { Ref } from 'react';
import { App, Data } from '@app/types';
import { GameSelectors, useAppSelector } from '@app/store';
export interface HandZone {
cards: Data.ServerInfo_Card[];
setNodeRef: Ref<HTMLDivElement>;
isOver: boolean;
handleZoneContextMenu: (e: React.MouseEvent<HTMLDivElement>) => void;
}
export interface UseHandZoneArgs {
gameId: number;
playerId: number;
canAct: boolean;
onZoneContextMenu?: (event: React.MouseEvent) => void;
}
export function useHandZone({
gameId,
playerId,
canAct,
onZoneContextMenu,
}: UseHandZoneArgs): HandZone {
const cards = useAppSelector((state) =>
GameSelectors.getCards(state, gameId, playerId, App.ZoneName.HAND),
);
// Match desktop: can't drop into a hand zone that isn't yours (judges
// aside; server enforces the same restriction). Today only the local
// HandZone mounts, but this guard future-proofs opponent-hand mirrors.
const { setNodeRef, isOver } = useDroppable({
id: `hand-${playerId}`,
data: { targetPlayerId: playerId, targetZone: App.ZoneName.HAND },
disabled: !canAct,
});
// Right-click anywhere inside the hand that doesn't land on a card opens
// the hand zone context menu (mulligan / reveal hand). Card-level right-
// click has its own handler on CardSlot.
const handleZoneContextMenu = (e: React.MouseEvent<HTMLDivElement>) => {
if (!onZoneContextMenu) {
return;
}
const target = e.target as HTMLElement;
if (target.closest('[data-card-id]')) {
return;
}
onZoneContextMenu(e);
};
return { cards, setNodeRef, isOver, handleZoneContextMenu };
}

View file

@ -1,10 +1,10 @@
import Tooltip from '@mui/material/Tooltip';
import { useCurrentGame, useWebClient } from '@app/hooks';
import { GameSelectors, useAppSelector } from '@app/store';
import { App, Data } from '@app/types';
import { App } from '@app/types';
import { cx } from '@app/utils';
import { usePhaseBar } from './usePhaseBar';
import './PhaseBar.css';
export interface PhaseBarProps {
@ -35,65 +35,8 @@ const PHASE_LABELS: ReadonlyArray<{
];
function PhaseBar({ gameId }: PhaseBarProps) {
const webClient = useWebClient();
const { game, isJudge, isStarted } = useCurrentGame(gameId);
const activePhase = useAppSelector((state) =>
gameId != null ? GameSelectors.getActivePhase(state, gameId) : undefined,
);
const localPlayerId = game?.localPlayerId;
const tableCards = useAppSelector((state) =>
gameId != null && localPlayerId != null
? GameSelectors.getCards(state, gameId, localPlayerId, App.ZoneName.TABLE)
: undefined,
);
// Desktop: only the active player (or a judge) can advance the phase.
const canAdvance =
gameId != null &&
game != null &&
isStarted &&
(isJudge || game.activePlayerId === game.localPlayerId);
const handlePhaseClick = (phase: App.Phase) => {
if (!canAdvance || gameId == null) {
return;
}
webClient.request.game.setActivePhase(gameId, { phase });
};
const handlePass = () => {
if (!canAdvance || gameId == null) {
return;
}
webClient.request.game.nextTurn(gameId);
};
// Desktop's untap-step double-click fires "Untap All" on the local player's
// table zone (cockatrice/src/game/player/player_actions.cpp actUntapAll).
// We replicate by sending one setCardAttr per tapped card; there is no
// batch variant on the wire.
const handleUntapAll = () => {
if (!canAdvance || gameId == null || !tableCards) {
return;
}
for (const card of tableCards) {
if (card.tapped) {
webClient.request.game.setCardAttr(gameId, {
zone: App.ZoneName.TABLE,
cardId: card.id,
attribute: Data.CardAttribute.AttrTapped,
attrValue: '0',
});
}
}
};
const handleDrawOne = () => {
if (!canAdvance || gameId == null) {
return;
}
webClient.request.game.drawCards(gameId, { number: 1 });
};
const { activePhase, canAdvance, handlePhaseClick, handlePass, handleUntapAll, handleDrawOne } =
usePhaseBar(gameId);
const onDoubleClickFor = (kind: 'untapAll' | 'drawCard' | undefined) => {
if (kind === 'untapAll') {

View file

@ -0,0 +1,76 @@
import { useCurrentGame, useWebClient } from '@app/hooks';
import { GameSelectors, useAppSelector } from '@app/store';
import { App, Data } from '@app/types';
export interface PhaseBar {
activePhase: App.Phase | undefined;
canAdvance: boolean;
handlePhaseClick: (phase: App.Phase) => void;
handlePass: () => void;
handleUntapAll: () => void;
handleDrawOne: () => void;
}
export function usePhaseBar(gameId: number | undefined): PhaseBar {
const webClient = useWebClient();
const { game, isJudge, isStarted } = useCurrentGame(gameId);
const activePhase = useAppSelector((state) =>
gameId != null ? GameSelectors.getActivePhase(state, gameId) : undefined,
);
const localPlayerId = game?.localPlayerId;
const tableCards = useAppSelector((state) =>
gameId != null && localPlayerId != null
? GameSelectors.getCards(state, gameId, localPlayerId, App.ZoneName.TABLE)
: undefined,
);
// Desktop: only the active player (or a judge) can advance the phase.
const canAdvance =
gameId != null &&
game != null &&
isStarted &&
(isJudge || game.activePlayerId === game.localPlayerId);
const handlePhaseClick = (phase: App.Phase) => {
if (!canAdvance || gameId == null) {
return;
}
webClient.request.game.setActivePhase(gameId, { phase });
};
const handlePass = () => {
if (!canAdvance || gameId == null) {
return;
}
webClient.request.game.nextTurn(gameId);
};
// Desktop's untap-step double-click fires "Untap All" on the local player's
// table zone (cockatrice/src/game/player/player_actions.cpp actUntapAll).
// We replicate by sending one setCardAttr per tapped card; there is no
// batch variant on the wire.
const handleUntapAll = () => {
if (!canAdvance || gameId == null || !tableCards) {
return;
}
for (const card of tableCards) {
if (card.tapped) {
webClient.request.game.setCardAttr(gameId, {
zone: App.ZoneName.TABLE,
cardId: card.id,
attribute: Data.CardAttribute.AttrTapped,
attrValue: '0',
});
}
}
};
const handleDrawOne = () => {
if (!canAdvance || gameId == null) {
return;
}
webClient.request.game.drawCards(gameId, { number: 1 });
};
return { activePhase, canAdvance, handlePhaseClick, handlePass, handleUntapAll, handleDrawOne };
}

View file

@ -1,9 +1,8 @@
import { useState } from 'react';
import { useWebClient } from '@app/hooks';
import { GameSelectors, useAppSelector } from '@app/store';
import { cx } from '@app/utils';
import type { Data } from '@app/types';
import { cssColor, usePlayerInfoPanel } from './usePlayerInfoPanel';
import './PlayerInfoPanel.css';
export interface PlayerInfoPanelProps {
@ -14,21 +13,6 @@ export interface PlayerInfoPanelProps {
onContextMenu?: (event: React.MouseEvent) => void;
}
function cssColor(c: { r: number; g: number; b: number; a: number } | undefined): string {
if (!c) {
return '#666';
}
return `rgba(${c.r}, ${c.g}, ${c.b}, ${(c.a ?? 255) / 255})`;
}
// Desktop renders Life larger/bolder than other counters (see
// cockatrice/src/game/player/player.cpp PlayerTarget sizing). We special-
// case the counter whose name is exactly 'Life' (case-insensitive) and
// pull it out of the regular counter list into a prominent life block.
function isLifeCounter(c: { name: string }): boolean {
return c.name.trim().toLowerCase() === 'life';
}
function PlayerInfoPanel({
gameId,
playerId,
@ -36,13 +20,20 @@ function PlayerInfoPanel({
onRequestCreateCounter,
onContextMenu,
}: PlayerInfoPanelProps) {
const webClient = useWebClient();
const player = useAppSelector((state) => GameSelectors.getPlayer(state, gameId, playerId));
const counters = useAppSelector((state) => GameSelectors.getCounters(state, gameId, playerId));
const hostId = useAppSelector((state) => GameSelectors.getHostId(state, gameId));
const [editingId, setEditingId] = useState<number | null>(null);
const [editDraft, setEditDraft] = useState('');
const {
player,
isHost,
lifeCounter,
otherCounters,
editingId,
editDraft,
setEditDraft,
beginEdit,
commitEdit,
cancelEdit,
handleIncrement,
handleDelete,
} = usePlayerInfoPanel({ gameId, playerId });
if (!player) {
return <div className="player-info-panel player-info-panel--empty" />;
@ -53,44 +44,6 @@ function PlayerInfoPanel({
const conceded = player.properties.conceded;
const ready = player.properties.readyStart;
const sideboardLocked = player.properties.sideboardLocked ?? false;
const isHost = hostId != null && hostId === playerId;
const allCounters = Object.values(counters);
const lifeCounter = allCounters.find(isLifeCounter);
const otherCounters = allCounters.filter((c) => !isLifeCounter(c));
const handleIncrement = (counterId: number, delta: number) => {
webClient.request.game.incCounter(gameId, { counterId, delta });
};
const handleDelete = (counterId: number) => {
webClient.request.game.delCounter(gameId, { counterId });
};
const beginEdit = (counterId: number, currentValue: number) => {
setEditingId(counterId);
setEditDraft(String(currentValue));
};
const commitEdit = (counterId: number) => {
const trimmed = editDraft.trim();
// Empty input cancels the edit (desktop inline edits treat blur-with-
// no-change and blur-with-empty-string identically). Prior behavior
// coerced '' → 0 because `Number('')` is 0 and `Number.isInteger(0)` is
// true, which surprised users expecting cancel-on-blank.
if (trimmed.length === 0) {
setEditingId(null);
return;
}
const value = Number(trimmed);
if (Number.isInteger(value)) {
webClient.request.game.setCounter(gameId, { counterId, value });
}
setEditingId(null);
};
const cancelEdit = () => {
setEditingId(null);
};
const renderCounterRow = (c: Data.ServerInfo_Counter) => (
<li

View file

@ -0,0 +1,106 @@
import { useState } from 'react';
import { useWebClient } from '@app/hooks';
import { GameSelectors, useAppSelector } from '@app/store';
import type { Data, Enriched } from '@app/types';
export function cssColor(c: { r: number; g: number; b: number; a: number } | undefined): string {
if (!c) {
return '#666';
}
return `rgba(${c.r}, ${c.g}, ${c.b}, ${(c.a ?? 255) / 255})`;
}
// Desktop renders Life larger/bolder than other counters (see
// cockatrice/src/game/player/player.cpp PlayerTarget sizing). We special-
// case the counter whose name is exactly 'Life' (case-insensitive) and
// pull it out of the regular counter list into a prominent life block.
export function isLifeCounter(c: { name: string }): boolean {
return c.name.trim().toLowerCase() === 'life';
}
export interface PlayerInfoPanel {
player: Enriched.PlayerEntry | undefined;
isHost: boolean;
lifeCounter: Data.ServerInfo_Counter | undefined;
otherCounters: Data.ServerInfo_Counter[];
editingId: number | null;
editDraft: string;
setEditDraft: (v: string) => void;
beginEdit: (counterId: number, currentValue: number) => void;
commitEdit: (counterId: number) => void;
cancelEdit: () => void;
handleIncrement: (counterId: number, delta: number) => void;
handleDelete: (counterId: number) => void;
}
export interface UsePlayerInfoPanelArgs {
gameId: number;
playerId: number;
}
export function usePlayerInfoPanel({
gameId,
playerId,
}: UsePlayerInfoPanelArgs): PlayerInfoPanel {
const webClient = useWebClient();
const player = useAppSelector((state) => GameSelectors.getPlayer(state, gameId, playerId));
const counters = useAppSelector((state) => GameSelectors.getCounters(state, gameId, playerId));
const hostId = useAppSelector((state) => GameSelectors.getHostId(state, gameId));
const [editingId, setEditingId] = useState<number | null>(null);
const [editDraft, setEditDraft] = useState('');
const isHost = hostId != null && hostId === playerId;
const allCounters = Object.values(counters);
const lifeCounter = allCounters.find(isLifeCounter);
const otherCounters = allCounters.filter((c) => !isLifeCounter(c));
const handleIncrement = (counterId: number, delta: number) => {
webClient.request.game.incCounter(gameId, { counterId, delta });
};
const handleDelete = (counterId: number) => {
webClient.request.game.delCounter(gameId, { counterId });
};
const beginEdit = (counterId: number, currentValue: number) => {
setEditingId(counterId);
setEditDraft(String(currentValue));
};
const commitEdit = (counterId: number) => {
const trimmed = editDraft.trim();
// Empty input cancels the edit (desktop inline edits treat blur-with-
// no-change and blur-with-empty-string identically). Prior behavior
// coerced '' → 0 because `Number('')` is 0 and `Number.isInteger(0)` is
// true, which surprised users expecting cancel-on-blank.
if (trimmed.length === 0) {
setEditingId(null);
return;
}
const value = Number(trimmed);
if (Number.isInteger(value)) {
webClient.request.game.setCounter(gameId, { counterId, value });
}
setEditingId(null);
};
const cancelEdit = () => {
setEditingId(null);
};
return {
player,
isHost,
lifeCounter,
otherCounters,
editingId,
editDraft,
setEditDraft,
beginEdit,
commitEdit,
cancelEdit,
handleIncrement,
handleDelete,
};
}

View file

@ -1,9 +1,7 @@
import { useMemo, useState } from 'react';
import Menu from '@mui/material/Menu';
import MenuItem from '@mui/material/MenuItem';
import { LoadingState, useCurrentGame, useSettings, useWebClient } from '@app/hooks';
import { GameSelectors, useAppSelector } from '@app/store';
import { useTurnControls } from './useTurnControls';
import './TurnControls.css';
@ -26,129 +24,31 @@ function TurnControls({
onToggleRotate90,
isRotated,
}: TurnControlsProps) {
const webClient = useWebClient();
const { game, localPlayer, isSpectator, isJudge, isHost, isStarted } = useCurrentGame(gameId);
const { status: settingsStatus, value: settings, update: updateSettings } = useSettings();
const invertVerticalCoordinate = settings?.invertVerticalCoordinate ?? false;
// Post-kick: the reducer has deleted the game from state but the dialog
// may still be mounted for a frame while `useGameLifecycle` navigates to
// /server. Every handler double-checks `game` so a trailing click can't
// fire a command against a game the server no longer has.
const hasLiveGame = gameId != null && game != null;
const [kickAnchor, setKickAnchor] = useState<HTMLElement | null>(null);
const opponents = useMemo(() => {
if (!game) {
return [];
}
return Object.values(game.players)
.filter((p) => p.properties.playerId !== game.localPlayerId)
.map((p) => ({
playerId: p.properties.playerId,
name: p.properties.userInfo?.name ?? `p${p.properties.playerId}`,
}));
}, [game]);
// Local arrows belong to `localPlayerId`; Remove Local Arrows iterates
// and deletes each one. Matches desktop's Player::actRemoveLocalArrows.
const localArrows = useAppSelector((state) =>
gameId != null && game != null
? GameSelectors.getArrows(state, gameId, game.localPlayerId)
: undefined,
);
const localArrowIds = useMemo(
() => (localArrows ? Object.keys(localArrows).map(Number) : []),
[localArrows],
);
// Players (judge or not) act as participants; pure spectators don't.
// Matches desktop: aConcede/aNextTurn are disabled when isSpectator() without
// judge privileges (see tab_game.cpp concede enablement + player_menu.cpp
// getLocalOrJudge gates).
const isParticipant = gameId != null && game != null && !isSpectator;
const isConceded = localPlayer?.properties.conceded ?? false;
const canAdvance =
gameId != null && game != null && isStarted &&
(isJudge || game.activePlayerId === game.localPlayerId);
const canLeave = gameId != null && game != null;
const canConcede = isParticipant && !isConceded;
const canUnconcede = isParticipant && isConceded;
// Rolling dice is a player action; judges may also roll. Pure spectators
// cannot (desktop exposes it through the player menu, which spectators
// don't receive).
const canRoll = gameId != null && (isParticipant || isJudge);
const canKick = gameId != null && isHost && opponents.length > 0;
const canRemoveArrows = hasLiveGame && localArrowIds.length > 0;
const handlePassTurn = () => {
if (!canAdvance || !hasLiveGame) {
return;
}
webClient.request.game.nextTurn(gameId);
};
const handleReverseTurn = () => {
if (!canAdvance || !hasLiveGame) {
return;
}
webClient.request.game.reverseTurn(gameId);
};
const handleNextPhase = () => {
if (!canAdvance || !hasLiveGame) {
return;
}
// Desktop wraps at 11 → 0 (the Phase enum is 010). When no phase is
// active yet (activePhase < 0 during the pre-game lobby), advance to
// Untap (0).
const current = game.activePhase;
const next = current >= 0 ? (current + 1) % 11 : 0;
webClient.request.game.setActivePhase(gameId, { phase: next });
};
const handleConcedeToggle = () => {
if (!hasLiveGame || (!canConcede && !canUnconcede)) {
return;
}
if (isConceded) {
onRequestUnconcede();
} else {
onRequestConcede();
}
};
const handleRemoveArrows = () => {
if (!canRemoveArrows) {
return;
}
for (const arrowId of localArrowIds) {
webClient.request.game.deleteArrow(gameId, { arrowId });
}
};
const handleLeave = () => {
if (!canLeave || !hasLiveGame) {
return;
}
webClient.request.game.leaveGame(gameId);
};
const handleToggleInvert = () => {
if (settingsStatus !== LoadingState.READY) {
return;
}
void updateSettings({ invertVerticalCoordinate: !invertVerticalCoordinate });
};
const handleKick = (playerId: number) => {
if (!hasLiveGame) {
return;
}
webClient.request.game.kickFromGame(gameId, { playerId });
setKickAnchor(null);
};
const {
isHost,
isConceded,
invertVerticalCoordinate,
settingsReady,
canAdvance,
canLeave,
canConcede,
canUnconcede,
canRoll,
canKick,
canRemoveArrows,
hasLiveGame,
opponents,
kickAnchor,
setKickAnchor,
handlePassTurn,
handleReverseTurn,
handleNextPhase,
handleConcedeToggle,
handleRemoveArrows,
handleLeave,
handleToggleInvert,
handleKick,
} = useTurnControls({ gameId, onRequestConcede, onRequestUnconcede });
return (
<div className="turn-controls" data-testid="turn-controls">
@ -216,7 +116,7 @@ function TurnControls({
className={`turn-controls__btn${invertVerticalCoordinate ? ' turn-controls__btn--active' : ''}`}
onClick={handleToggleInvert}
aria-pressed={invertVerticalCoordinate}
disabled={settingsStatus !== LoadingState.READY}
disabled={!settingsReady}
title="Flip battlefield row order (saved across sessions)"
>
Invert Rows

View file

@ -0,0 +1,197 @@
import { useMemo, useState } from 'react';
import { LoadingState, useCurrentGame, useSettings, useWebClient } from '@app/hooks';
import { GameSelectors, useAppSelector } from '@app/store';
export interface TurnControlsOpponent {
playerId: number;
name: string;
}
export interface TurnControls {
isHost: boolean;
isConceded: boolean;
invertVerticalCoordinate: boolean;
settingsReady: boolean;
canAdvance: boolean;
canLeave: boolean;
canConcede: boolean;
canUnconcede: boolean;
canRoll: boolean;
canKick: boolean;
canRemoveArrows: boolean;
hasLiveGame: boolean;
opponents: TurnControlsOpponent[];
kickAnchor: HTMLElement | null;
setKickAnchor: (el: HTMLElement | null) => void;
handlePassTurn: () => void;
handleReverseTurn: () => void;
handleNextPhase: () => void;
handleConcedeToggle: () => void;
handleRemoveArrows: () => void;
handleLeave: () => void;
handleToggleInvert: () => void;
handleKick: (playerId: number) => void;
}
export interface UseTurnControlsArgs {
gameId: number | undefined;
onRequestConcede: () => void;
onRequestUnconcede: () => void;
}
export function useTurnControls({
gameId,
onRequestConcede,
onRequestUnconcede,
}: UseTurnControlsArgs): TurnControls {
const webClient = useWebClient();
const { game, localPlayer, isSpectator, isJudge, isHost, isStarted } = useCurrentGame(gameId);
const { status: settingsStatus, value: settings, update: updateSettings } = useSettings();
const invertVerticalCoordinate = settings?.invertVerticalCoordinate ?? false;
// Post-kick: the reducer has deleted the game from state but the dialog
// may still be mounted for a frame while `useGameLifecycle` navigates to
// /server. Every handler double-checks `game` so a trailing click can't
// fire a command against a game the server no longer has.
const hasLiveGame = gameId != null && game != null;
const [kickAnchor, setKickAnchor] = useState<HTMLElement | null>(null);
const opponents = useMemo<TurnControlsOpponent[]>(() => {
if (!game) {
return [];
}
return Object.values(game.players)
.filter((p) => p.properties.playerId !== game.localPlayerId)
.map((p) => ({
playerId: p.properties.playerId,
name: p.properties.userInfo?.name ?? `p${p.properties.playerId}`,
}));
}, [game]);
// Local arrows belong to `localPlayerId`; Remove Local Arrows iterates
// and deletes each one. Matches desktop's Player::actRemoveLocalArrows.
const localArrows = useAppSelector((state) =>
gameId != null && game != null
? GameSelectors.getArrows(state, gameId, game.localPlayerId)
: undefined,
);
const localArrowIds = useMemo(
() => (localArrows ? Object.keys(localArrows).map(Number) : []),
[localArrows],
);
// Players (judge or not) act as participants; pure spectators don't.
// Matches desktop: aConcede/aNextTurn are disabled when isSpectator() without
// judge privileges (see tab_game.cpp concede enablement + player_menu.cpp
// getLocalOrJudge gates).
const isParticipant = gameId != null && game != null && !isSpectator;
const isConceded = localPlayer?.properties.conceded ?? false;
const canAdvance =
gameId != null && game != null && isStarted &&
(isJudge || game.activePlayerId === game.localPlayerId);
const canLeave = gameId != null && game != null;
const canConcede = isParticipant && !isConceded;
const canUnconcede = isParticipant && isConceded;
// Rolling dice is a player action; judges may also roll. Pure spectators
// cannot (desktop exposes it through the player menu, which spectators
// don't receive).
const canRoll = gameId != null && (isParticipant || isJudge);
const canKick = gameId != null && isHost && opponents.length > 0;
const canRemoveArrows = hasLiveGame && localArrowIds.length > 0;
const handlePassTurn = () => {
if (!canAdvance || !hasLiveGame) {
return;
}
webClient.request.game.nextTurn(gameId);
};
const handleReverseTurn = () => {
if (!canAdvance || !hasLiveGame) {
return;
}
webClient.request.game.reverseTurn(gameId);
};
const handleNextPhase = () => {
if (!canAdvance || !hasLiveGame) {
return;
}
// Desktop wraps at 11 → 0 (the Phase enum is 010). When no phase is
// active yet (activePhase < 0 during the pre-game lobby), advance to
// Untap (0).
const current = game.activePhase;
const next = current >= 0 ? (current + 1) % 11 : 0;
webClient.request.game.setActivePhase(gameId, { phase: next });
};
const handleConcedeToggle = () => {
if (!hasLiveGame || (!canConcede && !canUnconcede)) {
return;
}
if (isConceded) {
onRequestUnconcede();
} else {
onRequestConcede();
}
};
const handleRemoveArrows = () => {
if (!canRemoveArrows) {
return;
}
for (const arrowId of localArrowIds) {
webClient.request.game.deleteArrow(gameId, { arrowId });
}
};
const handleLeave = () => {
if (!canLeave || !hasLiveGame) {
return;
}
webClient.request.game.leaveGame(gameId);
};
const handleToggleInvert = () => {
if (settingsStatus !== LoadingState.READY) {
return;
}
void updateSettings({ invertVerticalCoordinate: !invertVerticalCoordinate });
};
const handleKick = (playerId: number) => {
if (!hasLiveGame) {
return;
}
webClient.request.game.kickFromGame(gameId, { playerId });
setKickAnchor(null);
};
return {
isHost,
isConceded,
invertVerticalCoordinate,
settingsReady: settingsStatus === LoadingState.READY,
canAdvance,
canLeave,
canConcede,
canUnconcede,
canRoll,
canKick,
canRemoveArrows,
hasLiveGame,
opponents,
kickAnchor,
setKickAnchor,
handlePassTurn,
handleReverseTurn,
handleNextPhase,
handleConcedeToggle,
handleRemoveArrows,
handleLeave,
handleToggleInvert,
handleKick,
};
}

View file

@ -3,10 +3,10 @@ import MenuItem from '@mui/material/MenuItem';
import Divider from '@mui/material/Divider';
import Check from '@mui/icons-material/Check';
import { useWebClient } from '@app/hooks';
import { GameSelectors, useAppSelector } from '@app/store';
import { App } from '@app/types';
import { useZoneContextMenu } from './useZoneContextMenu';
import './ZoneContextMenu.css';
export interface ZoneContextMenuProps {
@ -22,89 +22,52 @@ export interface ZoneContextMenuProps {
onRequestRevealZone: () => void;
}
function ZoneContextMenu({
isOpen,
anchorPosition,
gameId,
playerId,
zoneName,
onClose,
onRequestDrawN,
onRequestDumpN,
onRequestRevealTopN,
onRequestRevealZone,
}: ZoneContextMenuProps) {
const webClient = useWebClient();
function ZoneContextMenu(props: ZoneContextMenuProps) {
const {
isOpen,
anchorPosition,
zoneName,
onClose,
onRequestDrawN,
onRequestDumpN,
onRequestRevealTopN,
onRequestRevealZone,
} = props;
const {
ready,
alwaysReveal,
alwaysLook,
handleDrawOne,
handleShuffle,
handleRevealTop,
handleToggleAlwaysReveal,
handleToggleAlwaysLook,
runAndClose,
} = useZoneContextMenu(props);
const zone = useAppSelector((state) =>
playerId != null && zoneName != null
? GameSelectors.getZone(state, gameId, playerId, zoneName)
: undefined,
);
if (playerId == null || zoneName == null) {
if (!ready) {
return null;
}
const game = webClient.request.game;
const alwaysReveal = zone?.alwaysRevealTopCard ?? false;
const alwaysLook = zone?.alwaysLookAtTopCard ?? false;
// Close-then-act helpers (avoid duplicating onClose at every site).
const run = (fn: () => void) => () => {
fn();
onClose();
};
const handleDrawOne = () => {
game.drawCards(gameId, { number: 1 });
};
const handleShuffle = () => {
game.shuffle(gameId, { zoneName: App.ZoneName.DECK, start: 0, end: -1 });
};
const handleRevealTop = () => {
game.revealCards(gameId, {
zoneName: App.ZoneName.DECK,
playerId: -1,
topCards: 1,
});
};
const handleToggleAlwaysReveal = () => {
game.changeZoneProperties(gameId, {
zoneName: App.ZoneName.DECK,
alwaysRevealTopCard: !alwaysReveal,
});
};
const handleToggleAlwaysLook = () => {
game.changeZoneProperties(gameId, {
zoneName: App.ZoneName.DECK,
alwaysLookAtTopCard: !alwaysLook,
});
};
const menuItems: React.ReactNode[] = [];
if (zoneName === App.ZoneName.DECK) {
menuItems.push(
<MenuItem key="draw-one" onClick={run(handleDrawOne)}>Draw a card</MenuItem>,
<MenuItem key="draw-n" onClick={run(onRequestDrawN)}>Draw N cards</MenuItem>,
<MenuItem key="shuffle" onClick={run(handleShuffle)}>Shuffle</MenuItem>,
<MenuItem key="dump-n" onClick={run(onRequestDumpN)}>Dump top N</MenuItem>,
<MenuItem key="draw-one" onClick={runAndClose(handleDrawOne)}>Draw a card</MenuItem>,
<MenuItem key="draw-n" onClick={runAndClose(onRequestDrawN)}>Draw N cards</MenuItem>,
<MenuItem key="shuffle" onClick={runAndClose(handleShuffle)}>Shuffle</MenuItem>,
<MenuItem key="dump-n" onClick={runAndClose(onRequestDumpN)}>Dump top N</MenuItem>,
<Divider key="d1" />,
<MenuItem key="reveal-top" onClick={run(handleRevealTop)}>
<MenuItem key="reveal-top" onClick={runAndClose(handleRevealTop)}>
Reveal top card to all
</MenuItem>,
<MenuItem key="reveal-top-n" onClick={run(onRequestRevealTopN)}>
<MenuItem key="reveal-top-n" onClick={runAndClose(onRequestRevealTopN)}>
Reveal top N to
</MenuItem>,
<Divider key="d2" />,
<MenuItem
key="always-reveal"
onClick={run(handleToggleAlwaysReveal)}
onClick={runAndClose(handleToggleAlwaysReveal)}
className="zone-context-menu__toggle"
>
<span className="zone-context-menu__check" aria-hidden>
@ -114,7 +77,7 @@ function ZoneContextMenu({
</MenuItem>,
<MenuItem
key="always-look"
onClick={run(handleToggleAlwaysLook)}
onClick={runAndClose(handleToggleAlwaysLook)}
className="zone-context-menu__toggle"
>
<span className="zone-context-menu__check" aria-hidden>
@ -125,13 +88,13 @@ function ZoneContextMenu({
);
} else if (zoneName === App.ZoneName.GRAVE) {
menuItems.push(
<MenuItem key="reveal-grave" onClick={run(onRequestRevealZone)}>
<MenuItem key="reveal-grave" onClick={runAndClose(onRequestRevealZone)}>
Reveal graveyard to
</MenuItem>,
);
} else if (zoneName === App.ZoneName.EXILE) {
menuItems.push(
<MenuItem key="reveal-exile" onClick={run(onRequestRevealZone)}>
<MenuItem key="reveal-exile" onClick={runAndClose(onRequestRevealZone)}>
Reveal exile to
</MenuItem>,
);

View file

@ -0,0 +1,89 @@
import { useWebClient } from '@app/hooks';
import { GameSelectors, useAppSelector } from '@app/store';
import { App } from '@app/types';
export interface ZoneContextMenu {
ready: boolean;
alwaysReveal: boolean;
alwaysLook: boolean;
handleDrawOne: () => void;
handleShuffle: () => void;
handleRevealTop: () => void;
handleToggleAlwaysReveal: () => void;
handleToggleAlwaysLook: () => void;
runAndClose: (fn: () => void) => () => void;
}
export interface UseZoneContextMenuArgs {
gameId: number;
playerId: number | null;
zoneName: string | null;
onClose: () => void;
}
export function useZoneContextMenu({
gameId,
playerId,
zoneName,
onClose,
}: UseZoneContextMenuArgs): ZoneContextMenu {
const webClient = useWebClient();
const zone = useAppSelector((state) =>
playerId != null && zoneName != null
? GameSelectors.getZone(state, gameId, playerId, zoneName)
: undefined,
);
const ready = playerId != null && zoneName != null;
const alwaysReveal = zone?.alwaysRevealTopCard ?? false;
const alwaysLook = zone?.alwaysLookAtTopCard ?? false;
// Close-then-act helpers (avoid duplicating onClose at every site).
const runAndClose = (fn: () => void) => () => {
fn();
onClose();
};
const handleDrawOne = () => {
webClient.request.game.drawCards(gameId, { number: 1 });
};
const handleShuffle = () => {
webClient.request.game.shuffle(gameId, { zoneName: App.ZoneName.DECK, start: 0, end: -1 });
};
const handleRevealTop = () => {
webClient.request.game.revealCards(gameId, {
zoneName: App.ZoneName.DECK,
playerId: -1,
topCards: 1,
});
};
const handleToggleAlwaysReveal = () => {
webClient.request.game.changeZoneProperties(gameId, {
zoneName: App.ZoneName.DECK,
alwaysRevealTopCard: !alwaysReveal,
});
};
const handleToggleAlwaysLook = () => {
webClient.request.game.changeZoneProperties(gameId, {
zoneName: App.ZoneName.DECK,
alwaysLookAtTopCard: !alwaysLook,
});
};
return {
ready,
alwaysReveal,
alwaysLook,
handleDrawOne,
handleShuffle,
handleRevealTop,
handleToggleAlwaysReveal,
handleToggleAlwaysLook,
runAndClose,
};
}

View file

@ -1,4 +1,3 @@
import { useEffect, useState } from 'react';
import { styled } from '@mui/material/styles';
import { useTranslation } from 'react-i18next';
import { Select, MenuItem } from '@mui/material';
@ -13,20 +12,13 @@ import AddIcon from '@mui/icons-material/Add';
import EditRoundedIcon from '@mui/icons-material/Edit';
import ErrorOutlinedIcon from '@mui/icons-material/ErrorOutlined';
import { LoadingState, useKnownHosts, useReduxEffect, useWebClient } from '@app/hooks';
import { KnownHostDialog } from '@app/dialogs';
import { getHostPort, HostDTO } from '@app/services';
import { ServerTypes } from '@app/store';
import { App } from '@app/types';
import Toast from '../Toast/Toast';
import './KnownHosts.css';
import { TestConnection, useKnownHostsComponent } from './useKnownHostsComponent';
enum TestConnection {
TESTING = 'testing',
FAILED = 'failed',
SUCCESS = 'success',
}
import './KnownHosts.css';
const PREFIX = 'KnownHosts';
@ -57,115 +49,31 @@ const Root = styled('div')(({ theme }) => ({
},
},
}));
const KnownHosts = (props: any) => {
const { input, meta, disabled } = props;
const onChange: (value: HostDTO) => void = input.onChange;
const { touched, error, warning } = meta;
const { t } = useTranslation();
const webClient = useWebClient();
const knownHosts = useKnownHosts();
const [dialogState, setDialogState] = useState<{ open: boolean; edit: HostDTO | null }>({
open: false,
edit: null,
});
const [testingConnection, setTestingConnection] = useState<TestConnection | null>(null);
const [showCreateToast, setShowCreateToast] = useState(false);
const [showDeleteToast, setShowDeleteToast] = useState(false);
const [showEditToast, setShowEditToast] = useState(false);
const selectedHost =
knownHosts.status === LoadingState.READY ? knownHosts.value?.selectedHost : undefined;
const hosts = knownHosts.status === LoadingState.READY ? knownHosts.value?.hosts ?? [] : [];
const testConnection = (host: HostDTO) => {
setTestingConnection(TestConnection.TESTING);
webClient.request.authentication.testConnection({ ...getHostPort(host) });
};
useEffect(() => {
if (!selectedHost) {
return;
}
onChange(selectedHost);
testConnection(selectedHost);
}, [selectedHost]);
useReduxEffect(
() => {
setTestingConnection(TestConnection.SUCCESS);
},
ServerTypes.TEST_CONNECTION_SUCCESSFUL,
[]
);
useReduxEffect(
() => {
setTestingConnection(TestConnection.FAILED);
},
ServerTypes.TEST_CONNECTION_FAILED,
[]
);
const onPick = async (host: HostDTO) => {
if (knownHosts.status !== LoadingState.READY) {
return;
}
onChange(host);
await knownHosts.select(host.id!);
testConnection(host);
};
const openAddKnownHostDialog = () => {
setDialogState((s) => ({ ...s, open: true, edit: null }));
};
const openEditKnownHostDialog = (host: HostDTO) => {
setDialogState((s) => ({ ...s, open: true, edit: host }));
};
const closeKnownHostDialog = () => {
setDialogState((s) => ({ ...s, open: false }));
};
const handleDialogRemove = async ({ id }: { id: number }) => {
if (knownHosts.status !== LoadingState.READY) {
return;
}
await knownHosts.remove(id);
closeKnownHostDialog();
setShowDeleteToast(true);
};
const handleDialogSubmit = async ({
id,
name,
host,
port,
}: {
id?: number;
name: string;
host: string;
port: string;
}) => {
if (knownHosts.status !== LoadingState.READY) {
return;
}
if (id) {
await knownHosts.update(id, { name, host, port });
setShowEditToast(true);
} else {
const newHost: App.Host = { name, host, port, editable: true };
await knownHosts.add(newHost);
setShowCreateToast(true);
}
closeKnownHostDialog();
};
const {
hosts,
selectedHost,
testingConnection,
dialogState,
showCreateToast,
showDeleteToast,
showEditToast,
setShowCreateToast,
setShowDeleteToast,
setShowEditToast,
onPick,
openAddKnownHostDialog,
openEditKnownHostDialog,
closeKnownHostDialog,
handleDialogRemove,
handleDialogSubmit,
} = useKnownHostsComponent({ onChange });
return (
<Root className={'KnownHosts ' + classes.root}>

View file

@ -0,0 +1,167 @@
import { useEffect, useState } from 'react';
import { LoadingState, useKnownHosts, useReduxEffect, useWebClient } from '@app/hooks';
import { getHostPort, HostDTO } from '@app/services';
import { ServerTypes } from '@app/store';
import { App } from '@app/types';
export enum TestConnection {
TESTING = 'testing',
FAILED = 'failed',
SUCCESS = 'success',
}
export interface KnownHostsComponent {
hosts: App.Host[];
selectedHost: App.Host | undefined;
testingConnection: TestConnection | null;
dialogState: { open: boolean; edit: HostDTO | null };
showCreateToast: boolean;
showDeleteToast: boolean;
showEditToast: boolean;
setShowCreateToast: (v: boolean) => void;
setShowDeleteToast: (v: boolean) => void;
setShowEditToast: (v: boolean) => void;
onPick: (host: HostDTO) => Promise<void>;
openAddKnownHostDialog: () => void;
openEditKnownHostDialog: (host: HostDTO) => void;
closeKnownHostDialog: () => void;
handleDialogRemove: (args: { id: number }) => Promise<void>;
handleDialogSubmit: (args: {
id?: number;
name: string;
host: string;
port: string;
}) => Promise<void>;
}
export interface UseKnownHostsComponentArgs {
onChange: (value: HostDTO) => void;
}
export function useKnownHostsComponent({
onChange,
}: UseKnownHostsComponentArgs): KnownHostsComponent {
const webClient = useWebClient();
const knownHosts = useKnownHosts();
const [dialogState, setDialogState] = useState<{ open: boolean; edit: HostDTO | null }>({
open: false,
edit: null,
});
const [testingConnection, setTestingConnection] = useState<TestConnection | null>(null);
const [showCreateToast, setShowCreateToast] = useState(false);
const [showDeleteToast, setShowDeleteToast] = useState(false);
const [showEditToast, setShowEditToast] = useState(false);
const selectedHost =
knownHosts.status === LoadingState.READY ? knownHosts.value?.selectedHost : undefined;
const hosts = knownHosts.status === LoadingState.READY ? knownHosts.value?.hosts ?? [] : [];
const testConnection = (host: HostDTO) => {
setTestingConnection(TestConnection.TESTING);
webClient.request.authentication.testConnection({ ...getHostPort(host) });
};
useEffect(() => {
if (!selectedHost) {
return;
}
onChange(selectedHost);
testConnection(selectedHost);
}, [selectedHost]);
useReduxEffect(
() => {
setTestingConnection(TestConnection.SUCCESS);
},
ServerTypes.TEST_CONNECTION_SUCCESSFUL,
[],
);
useReduxEffect(
() => {
setTestingConnection(TestConnection.FAILED);
},
ServerTypes.TEST_CONNECTION_FAILED,
[],
);
const onPick = async (host: HostDTO) => {
if (knownHosts.status !== LoadingState.READY) {
return;
}
onChange(host);
await knownHosts.select(host.id!);
testConnection(host);
};
const openAddKnownHostDialog = () => {
setDialogState((s) => ({ ...s, open: true, edit: null }));
};
const openEditKnownHostDialog = (host: HostDTO) => {
setDialogState((s) => ({ ...s, open: true, edit: host }));
};
const closeKnownHostDialog = () => {
setDialogState((s) => ({ ...s, open: false }));
};
const handleDialogRemove = async ({ id }: { id: number }) => {
if (knownHosts.status !== LoadingState.READY) {
return;
}
await knownHosts.remove(id);
closeKnownHostDialog();
setShowDeleteToast(true);
};
const handleDialogSubmit = async ({
id,
name,
host,
port,
}: {
id?: number;
name: string;
host: string;
port: string;
}) => {
if (knownHosts.status !== LoadingState.READY) {
return;
}
if (id) {
await knownHosts.update(id, { name, host, port });
setShowEditToast(true);
} else {
const newHost: App.Host = { name, host, port, editable: true };
await knownHosts.add(newHost);
setShowCreateToast(true);
}
closeKnownHostDialog();
};
return {
hosts,
selectedHost,
testingConnection,
dialogState,
showCreateToast,
showDeleteToast,
showEditToast,
setShowCreateToast,
setShowDeleteToast,
setShowEditToast,
onPick,
openAddKnownHostDialog,
openEditKnownHostDialog,
closeKnownHostDialog,
handleDialogRemove,
handleDialogSubmit,
};
}

View file

@ -1,13 +1,11 @@
import React, { useMemo, useState } from 'react';
import { styled } from '@mui/material/styles';
import Popover from '@mui/material/Popover';
import { CardDTO, TokenDTO } from '@app/services';
import CardDetails from '../CardDetails/CardDetails';
import TokenDetails from '../TokenDetails/TokenDetails';
import { useCardCallout } from './useCardCallout';
import './CardCallout.css';
const PREFIX = 'CardCallout';
@ -28,31 +26,8 @@ const Root = styled('span')(() => ({
}));
const CardCallout = ({ name }) => {
const [card, setCard] = useState<CardDTO>(null);
const [token, setToken] = useState<TokenDTO>(null);
const [anchorEl, setAnchorEl] = useState<Element>(null);
useMemo(async () => {
const card = await CardDTO.get(name);
if (card) {
return setCard(card)
}
const token = await TokenDTO.get(name);
if (token) {
return setToken(token);
}
}, [name]);
const handlePopoverOpen = (event) => {
setAnchorEl(event.currentTarget);
};
const handlePopoverClose = () => {
setAnchorEl(null);
};
const open = Boolean(anchorEl);
const { card, token, anchorEl, open, handlePopoverOpen, handlePopoverClose } =
useCardCallout(name);
return (
<Root className='callout'>
@ -81,8 +56,8 @@ const CardCallout = ({ name }) => {
}}
>
<div className="callout-card">
{ card && (<CardDetails card={card} />) }
{ token && (<TokenDetails token={token} />) }
{card && (<CardDetails card={card} />)}
{token && (<TokenDetails token={token} />)}
</div>
</Popover>
)

View file

@ -1,11 +1,10 @@
import React, { useEffect, useState } from 'react';
import { NavLink, generatePath } from 'react-router-dom';
import type { ReactNode } from 'react';
import { App } from '@app/types';
import CardCallout from './CardCallout';
import { useParsedMessage } from './useMessage';
import './Message.css';
const Message = ({ message: { message } }) => (
@ -17,23 +16,12 @@ const Message = ({ message: { message } }) => (
);
const ParsedMessage = ({ message }) => {
const [messageChunks, setMessageChunks] = useState(null);
const [name, setName] = useState(null);
useEffect(() => {
const name = message.match(App.MESSAGE_SENDER_REGEX);
if (name) {
setName(name[1]);
}
setMessageChunks(parseMessage(message));
}, [message]);
const { name, chunks } = useParsedMessage(message, parseChunks);
return (
<div>
{ name && (<strong><PlayerLink name={name} />:</strong>) }
{ messageChunks }
{name && (<strong><PlayerLink name={name} />:</strong>)}
{chunks}
</div>
);
};
@ -44,14 +32,7 @@ const PlayerLink = ({ name, label = name }) => (
</NavLink>
);
function parseMessage(message) {
return message.replace(App.MESSAGE_SENDER_REGEX, '')
.split(App.CARD_CALLOUT_REGEX)
.filter(chunk => !!chunk)
.map(parseChunks);
}
function parseChunks(chunk, index) {
function parseChunks(chunk: string, index: number): ReactNode {
if (chunk.match(App.CARD_CALLOUT_REGEX)) {
const name = chunk.replace(App.CALLOUT_BOUNDARY_REGEX, '').trim();
return (<CardCallout name={name} key={index}></CardCallout>);
@ -68,9 +49,9 @@ function parseChunks(chunk, index) {
return chunk;
}
function parseUrlChunk(chunk) {
function parseUrlChunk(chunk: string): ReactNode {
return chunk.split(App.URL_REGEX)
.filter(urlChunk => !!urlChunk)
.filter((urlChunk) => !!urlChunk)
.map((urlChunk, index) => {
if (urlChunk.match(App.URL_REGEX)) {
return (<a className='link' href={urlChunk} key={index} target='_blank' rel='noopener noreferrer'>{urlChunk}</a>);
@ -80,9 +61,9 @@ function parseUrlChunk(chunk) {
});
}
function parseMentionChunk(chunk) {
function parseMentionChunk(chunk: string): ReactNode {
return chunk.split(App.MENTION_REGEX)
.filter(mentionChunk => !!mentionChunk)
.filter((mentionChunk) => !!mentionChunk)
.map((mentionChunk, index) => {
const mention = mentionChunk.match(App.MENTION_REGEX);

View file

@ -0,0 +1,42 @@
import { useMemo, useState } from 'react';
import { CardDTO, TokenDTO } from '@app/services';
export interface CardCallout {
card: CardDTO | null;
token: TokenDTO | null;
anchorEl: Element | null;
open: boolean;
handlePopoverOpen: (event: React.MouseEvent) => void;
handlePopoverClose: () => void;
}
export function useCardCallout(name: string): CardCallout {
const [card, setCard] = useState<CardDTO | null>(null);
const [token, setToken] = useState<TokenDTO | null>(null);
const [anchorEl, setAnchorEl] = useState<Element | null>(null);
useMemo(async () => {
const c = await CardDTO.get(name);
if (c) {
return setCard(c);
}
const t = await TokenDTO.get(name);
if (t) {
return setToken(t);
}
}, [name]);
const handlePopoverOpen = (event: React.MouseEvent) => {
setAnchorEl(event.currentTarget);
};
const handlePopoverClose = () => {
setAnchorEl(null);
};
const open = Boolean(anchorEl);
return { card, token, anchorEl, open, handlePopoverOpen, handlePopoverClose };
}

View file

@ -0,0 +1,33 @@
import { useEffect, useState, type ReactNode } from 'react';
import { App } from '@app/types';
export interface ParsedMessage {
name: string | null;
chunks: ReactNode[] | null;
}
export type ChunkParser = (chunk: string, index: number) => ReactNode;
export function useParsedMessage(message: string, parseChunk: ChunkParser): ParsedMessage {
const [chunks, setChunks] = useState<ReactNode[] | null>(null);
const [name, setName] = useState<string | null>(null);
useEffect(() => {
const match = message.match(App.MESSAGE_SENDER_REGEX);
if (match) {
setName(match[1]);
}
setChunks(parseMessage(message, parseChunk));
}, [message, parseChunk]);
return { name, chunks };
}
export function parseMessage(message: string, parseChunk: ChunkParser): ReactNode[] {
return message
.replace(App.MESSAGE_SENDER_REGEX, '')
.split(App.CARD_CALLOUT_REGEX)
.filter((chunk) => !!chunk)
.map(parseChunk);
}

View file

@ -1,53 +1,32 @@
import React, { useState } from 'react';
import { NavLink, generatePath } from 'react-router-dom';
import Menu from '@mui/material/Menu';
import MenuItem from '@mui/material/MenuItem';
import { Images } from '@app/images';
import { useWebClient } from '@app/hooks';
import { ServerSelectors } from '@app/store';
import { App, Data } from '@app/types';
import { useAppSelector } from '@app/store';
import { useUserDisplay } from './useUserDisplay';
import './UserDisplay.css';
interface UserDisplayProps {
user: Data.ServerInfo_User;
}
const UserDisplay = ({ user }: UserDisplayProps) => {
const buddyList = useAppSelector(state => ServerSelectors.getBuddyList(state));
const ignoreList = useAppSelector(state => ServerSelectors.getIgnoreList(state));
const [position, setPosition] = useState<{ x: number; y: number } | null>(null);
const webClient = useWebClient();
const { name, country } = user;
const handleClick = (event) => {
event.preventDefault();
setPosition({ x: event.clientX + 2, y: event.clientY + 4 });
};
const handleClose = () => setPosition(null);
const isABuddy = Boolean(buddyList[user.name]);
const isIgnored = Boolean(ignoreList[user.name]);
const onAddBuddy = () => {
webClient.request.session.addToBuddyList(user.name);
handleClose();
};
const onRemoveBuddy = () => {
webClient.request.session.removeFromBuddyList(user.name);
handleClose();
};
const onAddIgnore = () => {
webClient.request.session.addToIgnoreList(user.name);
handleClose();
};
const onRemoveIgnore = () => {
webClient.request.session.removeFromIgnoreList(user.name);
handleClose();
};
const {
position,
isABuddy,
isIgnored,
handleClick,
handleClose,
onAddBuddy,
onRemoveBuddy,
onAddIgnore,
onRemoveIgnore,
} = useUserDisplay(name);
return (
<div className="user-display">
@ -87,8 +66,4 @@ const UserDisplay = ({ user }: UserDisplayProps) => {
);
};
interface UserDisplayProps {
user: Data.ServerInfo_User;
}
export default UserDisplay;

View file

@ -0,0 +1,62 @@
import { useState } from 'react';
import { useWebClient } from '@app/hooks';
import { ServerSelectors, useAppSelector } from '@app/store';
export interface UserDisplay {
position: { x: number; y: number } | null;
isABuddy: boolean;
isIgnored: boolean;
handleClick: (event: React.MouseEvent) => void;
handleClose: () => void;
onAddBuddy: () => void;
onRemoveBuddy: () => void;
onAddIgnore: () => void;
onRemoveIgnore: () => void;
}
export function useUserDisplay(userName: string): UserDisplay {
const buddyList = useAppSelector((state) => ServerSelectors.getBuddyList(state));
const ignoreList = useAppSelector((state) => ServerSelectors.getIgnoreList(state));
const [position, setPosition] = useState<{ x: number; y: number } | null>(null);
const webClient = useWebClient();
const handleClick = (event: React.MouseEvent) => {
event.preventDefault();
setPosition({ x: event.clientX + 2, y: event.clientY + 4 });
};
const handleClose = () => setPosition(null);
const isABuddy = Boolean(buddyList[userName]);
const isIgnored = Boolean(ignoreList[userName]);
const onAddBuddy = () => {
webClient.request.session.addToBuddyList(userName);
handleClose();
};
const onRemoveBuddy = () => {
webClient.request.session.removeFromBuddyList(userName);
handleClose();
};
const onAddIgnore = () => {
webClient.request.session.addToIgnoreList(userName);
handleClose();
};
const onRemoveIgnore = () => {
webClient.request.session.removeFromIgnoreList(userName);
handleClose();
};
return {
position,
isABuddy,
isIgnored,
handleClick,
handleClose,
onAddBuddy,
onRemoveBuddy,
onAddIgnore,
onRemoveIgnore,
};
}