Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions client/src/components/board/AttackTargetLines.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";

import { usePreferencesStore } from "../../stores/preferencesStore.ts";
import type { MultiplayerBoardLayout } from "../../stores/preferencesStore.ts";
import { useUiStore } from "../../stores/uiStore.ts";
import { useGameStore } from "../../stores/gameStore.ts";
import { usePlayerId } from "../../hooks/usePlayerId.ts";
Expand Down Expand Up @@ -61,13 +62,16 @@ function AttackArrowPath({ arrow, isMinimal }: { arrow: AttackArrowData; isMinim
*
* Player-target arrows only draw in multiplayer (>2 players); in 1v1 the
* player attack is implicit and drawing would be visual noise. */
export function AttackTargetLines() {
export function AttackTargetLines({
effectiveMultiplayerBoardLayout,
}: {
effectiveMultiplayerBoardLayout: MultiplayerBoardLayout;
}) {
const gameState = useGameStore((s) => s.gameState);
const combat = gameState?.combat ?? null;
const objects = gameState?.objects;
const focusedOpponent = useUiStore((s) => s.focusedOpponent) as PlayerId | null;
const vfxQuality = usePreferencesStore((s) => s.vfxQuality);
const multiplayerBoardLayout = usePreferencesStore((s) => s.multiplayerBoardLayout);
const localPlayerId = usePlayerId();
const isMinimal = vfxQuality === "minimal";

Expand All @@ -77,9 +81,9 @@ export function AttackTargetLines() {
gameState,
localPlayerId,
focusedOpponent,
multiplayerBoardLayout,
effectiveMultiplayerBoardLayout,
)),
[focusedOpponent, gameState, localPlayerId, multiplayerBoardLayout],
[effectiveMultiplayerBoardLayout, focusedOpponent, gameState, localPlayerId],
);

const blockedAttackerIds = useMemo<Set<number>>(() => {
Expand Down
12 changes: 8 additions & 4 deletions client/src/components/board/BlockAssignmentLines.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";

import { usePreferencesStore } from "../../stores/preferencesStore.ts";
import type { MultiplayerBoardLayout } from "../../stores/preferencesStore.ts";
import { blockerAssignmentPairs, useUiStore } from "../../stores/uiStore.ts";
import { useGameStore } from "../../stores/gameStore.ts";
import { usePlayerId } from "../../hooks/usePlayerId.ts";
Expand All @@ -15,14 +16,17 @@ const BLOCK_COLOR = "rgba(56,189,248,0.95)";
const BLOCK_COLOR_HEAD = "rgba(56,189,248,0.9)";
const EMPTY_BLOCKER_ASSIGNMENT_PAIRS: readonly BlockerAssignmentPair[] = [];

export function BlockAssignmentLines() {
export function BlockAssignmentLines({
effectiveMultiplayerBoardLayout,
}: {
effectiveMultiplayerBoardLayout: MultiplayerBoardLayout;
}) {
const blockerAssignments = useUiStore((s) => s.blockerAssignments);
const combatMode = useUiStore((s) => s.combatMode);
const focusedOpponent = useUiStore((s) => s.focusedOpponent) as PlayerId | null;
const combat = useGameStore((s) => s.gameState?.combat ?? null);
const objects = useGameStore((s) => s.gameState?.objects);
const vfxQuality = usePreferencesStore((s) => s.vfxQuality);
const multiplayerBoardLayout = usePreferencesStore((s) => s.multiplayerBoardLayout);
const localPlayerId = usePlayerId();

const gameState = useGameStore((s) => s.gameState);
Expand All @@ -32,9 +36,9 @@ export function BlockAssignmentLines() {
gameState,
localPlayerId,
focusedOpponent,
multiplayerBoardLayout,
effectiveMultiplayerBoardLayout,
)),
[focusedOpponent, gameState, localPlayerId, multiplayerBoardLayout],
[effectiveMultiplayerBoardLayout, focusedOpponent, gameState, localPlayerId],
);

const pairs = useMergedPairs(
Expand Down
7 changes: 4 additions & 3 deletions client/src/components/board/GameBoard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { memo, useMemo } from "react";
import { useTranslation } from "react-i18next";

import type { PlayerId } from "../../adapter/types.ts";
import type { MultiplayerBoardLayout } from "../../stores/preferencesStore.ts";
import { useGameStore } from "../../stores/gameStore.ts";
import { useUiStore } from "../../stores/uiStore.ts";
import { useCanActForWaitingState, usePerspectivePlayerId, usePlayerId } from "../../hooks/usePlayerId.ts";
Expand All @@ -25,9 +26,9 @@ import { OpponentSeatPane } from "./OpponentSeatPane.tsx";
import { PlayerArea } from "./PlayerArea.tsx";
import { PlanechasePanel } from "./PlanechasePanel.tsx";
import { DraggableWidget } from "../flexlayout/DraggableWidget.tsx";
import { usePreferencesStore } from "../../stores/preferencesStore.ts";

interface GameBoardProps {
effectiveMultiplayerBoardLayout: MultiplayerBoardLayout;
oppHud?: React.ReactNode;
playerHud?: React.ReactNode;
showOpponentCards?: boolean;
Expand All @@ -36,6 +37,7 @@ interface GameBoardProps {
}

export const GameBoard = memo(function GameBoard({
effectiveMultiplayerBoardLayout,
oppHud,
playerHud,
showOpponentCards = false,
Expand All @@ -46,7 +48,6 @@ export const GameBoard = memo(function GameBoard({
const gameState = useGameStore((s) => s.gameState);
const waitingFor = useGameStore((s) => s.waitingFor);
const legalActionsByObject = useGameStore((s) => s.legalActionsByObject);
const multiplayerBoardLayout = usePreferencesStore((s) => s.multiplayerBoardLayout);
const blockerAssignments = useUiStore((s) => s.blockerAssignments);
const localPlayerId = usePlayerId();
const myId = usePerspectivePlayerId();
Expand Down Expand Up @@ -76,7 +77,7 @@ export const GameBoard = memo(function GameBoard({
]),
);
}, [gameState, opponents]);
const splitBoardActive = isSplitBoardActive(multiplayerBoardLayout, getSeatCount(gameState));
const splitBoardActive = isSplitBoardActive(effectiveMultiplayerBoardLayout, getSeatCount(gameState));

const sortedPlayerCreatures = useMemo(() => {
if (splitBoardActive || !focusedBattlefieldView) return undefined;
Expand Down
13 changes: 9 additions & 4 deletions client/src/components/board/GroupedPermanent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@

import type { GameObject, ObjectId, WaitingFor } from "../../adapter/types.ts";
import { dispatchAction } from "../../game/dispatch.ts";
import { usePlayerId } from "../../hooks/usePlayerId.ts";
import { useCanActForWaitingState, usePlayerId } from "../../hooks/usePlayerId.ts";
import { useGameStore } from "../../stores/gameStore.ts";
import type { GroupedPermanent as GroupedPermanentType } from "../../viewmodel/battlefieldProps";
import {
Expand Down Expand Up @@ -82,6 +82,8 @@
case "HarmonizeTapChoice":
case "KeepWithinTotalPowerChoice":
case "KeepExactPermanentsChoice":
case "UntapChoice":
case "ChooseUntapSubset":
return waitingFor.data.player;
default:
return null;
Expand All @@ -98,6 +100,7 @@
const [pickerOpen, setPickerOpen] = useState(false);
const collapsedAnchorRef = useRef<HTMLDivElement | null>(null);
const playerId = usePlayerId();
const canActForWaitingState = useCanActForWaitingState();
const battlefieldCardDisplay = usePreferencesStore((s) => s.battlefieldCardDisplay);
const combatMode = useUiStore((s) => s.combatMode);
const selectedAttackers = useUiStore((s) => s.selectedAttackers);
Expand Down Expand Up @@ -127,16 +130,17 @@

const pickerContext = useMemo<PickerContext | null>(() => {
if (renderMode !== "collapsed") return null;
if (waitingForPlayer(waitingFor) !== playerId) return null;

const boardChoice = getBoardChoiceView(waitingFor, gameObjects);
if (boardChoice) {
if (boardChoice && canActForWaitingState) {
const eligibleIds = group.ids.filter((id) => boardChoiceObjectIds.has(id));
return eligibleIds.length > 0
? { mode: "boardChoice", eligibleIds, choice: boardChoice }
: null;
}

if (waitingForPlayer(waitingFor) !== playerId) return null;

if (combatMode === "attackers") {
const eligibleIds = group.ids.filter((id) => validAttackerIds.has(id));
return eligibleIds.length > 0 ? { mode: "attackers", eligibleIds } : null;
Expand Down Expand Up @@ -174,9 +178,10 @@
}

return null;
}, [

Check warning on line 181 in client/src/components/board/GroupedPermanent.tsx

View workflow job for this annotation

GitHub Actions / Frontend (lint, type-check, test)

React Hook useMemo has an unnecessary dependency: 'blockerAssignments'. Either exclude it or remove the dependency array
blockerAssignments,
boardChoiceObjectIds,
canActForWaitingState,
combatClickHandler,
combatMode,
gameObjects,
Expand Down Expand Up @@ -685,7 +690,7 @@
</div>
<button
type="button"
className="w-full rounded bg-sky-700 px-2 py-1 font-bold text-white disabled:cursor-not-allowed disabled:bg-slate-800 disabled:text-slate-500"
className="w-full rounded bg-sky-700 px-2 py-1 font-bold text-white disabled:cursor-not-allowed disabled:bg-slate-800 disabled:text-white/50"
disabled={!canConfirm}
onClick={() => {
dispatchAction(buildBoardChoiceAction(choice, selectedForChoice));
Expand Down
13 changes: 10 additions & 3 deletions client/src/components/board/PermanentCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { useTranslation } from "react-i18next";

import type { AbilityBlockKind, GameAction, GameObject, Keyword } from "../../adapter/types.ts";
import { cardImageLookup, tokenFiltersForObject } from "../../services/cardImageLookup.ts";
import { usePlayerId } from "../../hooks/usePlayerId.ts";
import { useCanActForWaitingState, usePlayerId } from "../../hooks/usePlayerId.ts";
import { dispatchAction } from "../../game/dispatch.ts";
import { ArtCropCard } from "../card/ArtCropCard.tsx";
import { CardImage } from "../card/CardImage.tsx";
Expand Down Expand Up @@ -213,6 +213,8 @@ function selectedBoardChoiceGlowClass(intent: BoardChoiceIntent): string {
return "ring-2 ring-red-400 shadow-[0_0_14px_4px_rgba(248,113,113,0.55),inset_0_0_18px_5px_rgba(248,113,113,0.3)]";
case "tap":
return "ring-2 ring-emerald-400 shadow-[0_0_14px_4px_rgba(52,211,153,0.55),inset_0_0_18px_5px_rgba(52,211,153,0.3)]";
case "untap":
return "ring-2 ring-cyan-300 shadow-[0_0_14px_4px_rgba(103,232,249,0.55),inset_0_0_18px_5px_rgba(103,232,249,0.3)]";
case "blight":
return "ring-2 ring-purple-400 shadow-[0_0_14px_4px_rgba(192,132,252,0.55),inset_0_0_18px_5px_rgba(192,132,252,0.3)]";
case "ringBearer":
Expand All @@ -233,6 +235,8 @@ function availableBoardChoiceGlowClass(intent: BoardChoiceIntent): string {
return "ring-2 ring-red-300/80 shadow-[0_0_10px_3px_rgba(248,113,113,0.35)]";
case "tap":
return "ring-2 ring-emerald-300/70 shadow-[0_0_10px_3px_rgba(74,222,128,0.35)]";
case "untap":
return "ring-2 ring-cyan-300/80 shadow-[0_0_10px_3px_rgba(103,232,249,0.4)]";
case "blight":
return "ring-2 ring-purple-300/80 shadow-[0_0_10px_3px_rgba(216,180,254,0.35)]";
case "ringBearer":
Expand All @@ -253,6 +257,8 @@ function boardChoiceBadgeClass(intent: BoardChoiceIntent): string {
return "bg-red-500 text-white";
case "tap":
return "bg-emerald-500 text-emerald-950";
case "untap":
return "bg-cyan-400 text-cyan-950";
case "blight":
return "bg-purple-500 text-white";
case "ringBearer":
Expand All @@ -277,6 +283,7 @@ export const PermanentCard = memo(function PermanentCard({
const { t } = useTranslation("game");
const isMobile = useIsMobile();
const playerId = usePlayerId();
const canActForWaitingState = useCanActForWaitingState();
const gameObjects = useGameStore((s) => s.gameState?.objects);
const obj = useGameStore((s) => s.gameState?.objects[objectId]);
const battlefieldKeywordBadges = useGameStore(
Expand Down Expand Up @@ -392,8 +399,8 @@ export const PermanentCard = memo(function PermanentCard({
const waitingFor = useGameStore((s) => s.waitingFor);
const boardChoice = useMemo(() => {
const choice = getBoardChoiceView(waitingFor, gameObjects);
return choice?.player === playerId ? choice : null;
}, [gameObjects, playerId, waitingFor]);
return canActForWaitingState ? choice : null;
}, [canActForWaitingState, gameObjects, waitingFor]);
const equipTargetChoice = useGameStore((s) =>
s.waitingFor?.type === "EquipTarget" && s.waitingFor.data.player === playerId
? s.waitingFor.data
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ describe("BlockAssignmentLines", () => {
document.body.append(anchor);
}

render(<BlockAssignmentLines />);
render(<BlockAssignmentLines effectiveMultiplayerBoardLayout="focused" />);
act(() => {
rafCallbacks.shift()?.(0);
});
Expand Down Expand Up @@ -107,7 +107,7 @@ describe("BlockAssignmentLines", () => {
attackerAnchor.dataset.objectId = "100";
document.body.append(hud, attackerAnchor);

render(<BlockAssignmentLines />);
render(<BlockAssignmentLines effectiveMultiplayerBoardLayout="focused" />);
act(() => {
rafCallbacks.shift()?.(0);
});
Expand Down
4 changes: 2 additions & 2 deletions client/src/components/board/__tests__/GameBoard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ describe("GameBoard multiplayer layout", () => {
});

it("defaults to the focused opponent plus local player", () => {
render(<GameBoard oppHud={<div />} playerHud={<div />} />);
render(<GameBoard effectiveMultiplayerBoardLayout="focused" oppHud={<div />} playerHud={<div />} />);

expect(screen.getByTestId("player-area-2")).toBeInTheDocument();
expect(screen.getByTestId("player-area-0")).toBeInTheDocument();
Expand All @@ -103,7 +103,7 @@ describe("GameBoard multiplayer layout", () => {
it("renders each live opponent once plus local player in split mode", () => {
usePreferencesStore.setState({ multiplayerBoardLayout: "split" });

render(<GameBoard oppHud={<div data-testid="global-opponent-hud" />} playerHud={<div />} />);
render(<GameBoard effectiveMultiplayerBoardLayout="split" oppHud={<div data-testid="global-opponent-hud" />} playerHud={<div />} />);

for (const playerId of [0, 1, 2, 3]) {
expect(screen.getAllByTestId(`player-area-${playerId}`)).toHaveLength(1);
Expand Down
22 changes: 22 additions & 0 deletions client/src/components/board/__tests__/GroupedPermanent.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,28 @@ describe("GroupedPermanentDisplay collapsed creature groups", () => {
});
});

it("uses delegated untap authority for a collapsed group picker", () => {
const waitingFor: WaitingFor = {
type: "UntapChoice",
data: { player: 1, candidates: [1] },
};
const gameState = {
...makeState(waitingFor),
turn_decision_controller: 0,
active_player: 1,
};
useGameStore.setState({ gameState, waitingFor });
renderGroup({ boardChoiceObjectIds: new Set([1]) });

fireEvent.click(screen.getByRole("button", { name: "Choose Saproling token" }));
fireEvent.click(screen.getByRole("button", { name: "Untap" }));

expect(dispatchAction).toHaveBeenCalledWith({
type: "ChooseUntap",
data: { object_id: 1, untap: true },
});
});

it("sacrifices one of many identical tokens with a single action (no #1-#N list) — #4375", () => {
const waitingFor: WaitingFor = {
type: "EffectZoneChoice",
Expand Down
18 changes: 18 additions & 0 deletions client/src/components/board/__tests__/PermanentCard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1026,6 +1026,24 @@ describe("PermanentCard", () => {
});
});

it("submits an authorized untap decision from the board", () => {
const gameState: GameState = {
...makeState(),
turn_decision_controller: 0,
active_player: 1,
waiting_for: { type: "UntapChoice", data: { player: 1, candidates: [1] } },
};
useGameStore.setState({ gameState, waitingFor: gameState.waiting_for });
const { container } = renderPermanent(new Set(), new Set(), new Set([1]));

fireEvent.click(container.querySelector('[data-object-id="1"]') as HTMLElement);

expect(dispatchAction).toHaveBeenCalledWith({
type: "ChooseUntap",
data: { object_id: 1, untap: true },
});
});

it("counts only active board-choice selections when enforcing count limits", () => {
const gameState: GameState = {
...makeState(),
Expand Down
Loading
Loading