Skip to content
Merged
2 changes: 1 addition & 1 deletion client/src/adapter/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -322,7 +322,7 @@ export interface MeldSelection {
// display-only badges + Confirm gating. `#[serde(tag = "kind")]` in the engine.
export type CombatRequirement =
| { kind: "MustAttack"; players: PlayerId[]; sources?: ObjectId[] }
| { kind: "MustBlock"; sources?: ObjectId[] }
| { kind: "MustBlock"; sources?: ObjectId[]; attackers?: ObjectId[] }
| { kind: "CantAttack"; sources?: ObjectId[] }
| { kind: "CantBlock"; sources?: ObjectId[] };

Expand Down
74 changes: 29 additions & 45 deletions client/src/components/board/ActionButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,8 @@ import { usePhaseInfo } from "../../hooks/usePhaseInfo.ts";
import { useGameStore } from "../../stores/gameStore.ts";
import { DRAFT_BOT_AI_SEAT, useMultiplayerDraftStore } from "../../stores/multiplayerDraftStore.ts";
import { useMultiplayerStore } from "../../stores/multiplayerStore.ts";
import { useUiStore } from "../../stores/uiStore.ts";
import { blockerAssignmentPairs, useUiStore } from "../../stores/uiStore.ts";
import { buildAttacks, hasMultipleAttackTargets, getValidAttackTargets, getValidAttackTargetsByAttacker } from "../../utils/combat.ts";
import { useBlockRequirements } from "../combat/useBlockRequirements.ts";
import { useBlockerConstraints } from "../combat/useBlockerConstraints.ts";
import { gameButtonClass } from "../ui/buttonStyles.ts";
import { GameplayTooltip } from "../ui/GameplayTooltip.tsx";
import { AttackTargetPicker } from "../controls/AttackTargetPicker.tsx";
Expand Down Expand Up @@ -72,20 +70,10 @@ export function ActionButton() {
const setCombatMode = useUiStore((s) => s.setCombatMode);
const setCombatClickHandler = useUiStore((s) => s.setCombatClickHandler);

// Engine-declared per-attacker minimum-blocker requirements (menace /
// "blocked by N or more"). Used to block confirmation while any attacker is
// under-assigned, so the player gets a clear message instead of an engine
// rejection (CR 702.111b / CR 509.1b).
const { byAttacker: blockRequirements } = useBlockRequirements();
const incompleteBlockCount = useMemo(
() => Array.from(blockRequirements.values()).filter((r) => r.status === "incomplete").length,
[blockRequirements],
const blockerPairs = useMemo(
() => blockerAssignmentPairs(blockerAssignments),
[blockerAssignments],
);
// CR 509.1c: engine-provided must-block gating. Attacker must-attack
// requirements are NOT gated client-side — the engine strictly validates the
// declaration (CR 508.1d) and rejects an illegal submission; the must-attack
// badges (see AttackRequirementBadges / useAttackRequirements) are display only.
const { unsatisfiedMustBlockCount } = useBlockerConstraints();

const canCompanionToHand = useGameStore((s) =>
s.legalActions.some((a) => a.type === "CompanionToHand"),
Expand Down Expand Up @@ -149,26 +137,32 @@ export function ActionButton() {
[waitingFor],
);

// A new declaration prompt invalidates a partially selected blocker from the
// prior prompt, even though both prompts share the same combat mode.
const blockerPrompt = waitingFor?.type === "DeclareBlockers" ? waitingFor : null;
useEffect(() => {
setPendingBlocker(null);
}, [blockerPrompt]);

// Blocker click handler
const handleBlockerClick = useCallback(
(objectId: ObjectId) => {
// Click an already-assigned blocker to unassign
if (blockerAssignments.has(objectId)) {
removeBlockerAssignment(objectId);
// Selecting a blocker never clears its other assignments: one blocker can
// be assigned to multiple attackers. A second click on an attacker toggles
// only that pair, using the engine-provided candidate list.
if (validBlockerIds.includes(objectId) && validBlockTargets[objectId]?.length > 0) {
setPendingBlocker((current) => current === objectId ? null : objectId);
return;
}

if (pendingBlocker === null) {
// First click: select a valid blocker (must have at least one valid target)
if (validBlockerIds.includes(objectId) && validBlockTargets[objectId]?.length > 0) {
setPendingBlocker(objectId);
}
} else {
// Second click: assign to an attacker (only if engine says this pair is valid)
if (pendingBlocker !== null) {
const validTargetsForBlocker = validBlockTargets[pendingBlocker] ?? [];
if (combatAttackerIds.includes(objectId) && validTargetsForBlocker.includes(objectId)) {
assignBlocker(pendingBlocker, objectId);
setPendingBlocker(null);
if (blockerAssignments.get(pendingBlocker)?.has(objectId)) {
removeBlockerAssignment(pendingBlocker, objectId);
} else {
assignBlocker(pendingBlocker, objectId);
}
}
}
},
Expand Down Expand Up @@ -241,7 +235,7 @@ export function ActionButton() {
function handleConfirmBlockers() {
dispatchAction({
type: "DeclareBlockers",
data: { assignments: Array.from(blockerAssignments.entries()) },
data: { assignments: blockerPairs },
});
}

Expand Down Expand Up @@ -317,14 +311,14 @@ export function ActionButton() {

{mode === "combat-blockers" && (
<>
{blockerAssignments.size > 0 ? (
{blockerPairs.length > 0 ? (
<>
<button
disabled={actionBlocked || incompleteBlockCount > 0 || unsatisfiedMustBlockCount > 0}
disabled={actionBlocked}
onClick={handleConfirmBlockers}
className={gameButtonClass({ tone: "emerald", size: "md", disabled: actionBlocked || incompleteBlockCount > 0 || unsatisfiedMustBlockCount > 0, className: primaryButtonClass })}
className={gameButtonClass({ tone: "emerald", size: "md", disabled: actionBlocked, className: primaryButtonClass })}
>
{t("actionButton.confirmBlockers", { count: blockerAssignments.size })}
{t("actionButton.confirmBlockers", { count: blockerPairs.length })}
</button>
<button
disabled={actionBlocked}
Expand All @@ -336,9 +330,9 @@ export function ActionButton() {
</>
) : (
<button
disabled={actionBlocked || unsatisfiedMustBlockCount > 0}
disabled={actionBlocked}
onClick={() => handleSkipConfirm("blockers")}
className={gameButtonClass({ tone: "slate", size: "md", disabled: actionBlocked || unsatisfiedMustBlockCount > 0, className: primaryButtonClass })}
className={gameButtonClass({ tone: "slate", size: "md", disabled: actionBlocked, className: primaryButtonClass })}
>
{skipArmed === "blockers"
? t("actionButton.blockWithNoneConfirm")
Expand All @@ -350,16 +344,6 @@ export function ActionButton() {
{t("actionButton.selectAttackerForBlocker")}
</div>
)}
{pendingBlocker === null && incompleteBlockCount > 0 && (
<div className="absolute bottom-full right-0 mb-3 whitespace-nowrap rounded-[8px] border border-amber-300/30 bg-amber-950/95 px-4 py-2 text-sm font-medium text-amber-100 shadow-lg">
{t("combat.blockIncomplete", { count: incompleteBlockCount })}
</div>
)}
{pendingBlocker === null && incompleteBlockCount === 0 && unsatisfiedMustBlockCount > 0 && (
<div className="absolute bottom-full right-0 mb-3 whitespace-nowrap rounded-[8px] border border-rose-300/30 bg-rose-950/95 px-4 py-2 text-sm font-medium text-rose-100 shadow-lg">
{t("combat.unsatisfiedMustBlock", { count: unsatisfiedMustBlockCount })}
</div>
)}
</>
)}

Expand Down
27 changes: 17 additions & 10 deletions client/src/components/board/BlockAssignmentLines.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,18 @@ import { useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";

import { usePreferencesStore } from "../../stores/preferencesStore.ts";
import { useUiStore } from "../../stores/uiStore.ts";
import { blockerAssignmentPairs, useUiStore } from "../../stores/uiStore.ts";
import { useGameStore } from "../../stores/gameStore.ts";
import { usePlayerId } from "../../hooks/usePlayerId.ts";
import { useRafPositions } from "../../hooks/useRafPositions.ts";
import { arcPath } from "../../hooks/useAttackerArrowPositions.ts";
import { objectAnchorSelector } from "../../utils/objectAnchorSelector.ts";
import { getVisibleBoardPlayerIds, isOneOnOne } from "../../viewmodel/gameStateView.ts";
import type { ObjectId, PlayerId } from "../../adapter/types.ts";
import { filterVisibleBlockerPairs } from "./blockAssignmentVisibility.ts";
import {
filterVisibleBlockerPairs,
type BlockerAssignmentPair,
} from "./blockAssignmentVisibility.ts";

const BLOCK_COLOR = "rgba(56,189,248,0.95)";
const BLOCK_COLOR_HEAD = "rgba(56,189,248,0.9)";
Expand Down Expand Up @@ -89,10 +92,10 @@ export function BlockAssignmentLines() {
</marker>
</defs>
{showCreatureArrows &&
Array.from(positions.entries()).map(([blockerId, pos]) => {
Array.from(positions.entries()).map(([pairKey, pos]) => {
const d = arcPath(pos.from, pos.to);
return (
<g key={blockerId}>
<g key={pairKey}>
<path
d={d}
stroke="black"
Expand Down Expand Up @@ -219,18 +222,22 @@ function useHudBlockIndicators(
}

function useMergedPairs(
uiAssignments: Map<ObjectId, ObjectId>,
uiAssignments: ReadonlyMap<ObjectId, ReadonlySet<ObjectId>>,
engineAssignments: Record<string, ObjectId[]> | null,
): Map<ObjectId, ObjectId> {
): BlockerAssignmentPair[] {
return useMemo(() => {
const merged = new Map(uiAssignments);
const merged = new Map<string, BlockerAssignmentPair>();
for (const pair of blockerAssignmentPairs(uiAssignments)) {
merged.set(pair.join(":"), pair);
}
if (engineAssignments) {
for (const [blockerId, attackerIds] of Object.entries(engineAssignments)) {
if (attackerIds.length > 0) {
merged.set(Number(blockerId), attackerIds[0]);
for (const attackerId of attackerIds) {
const pair: BlockerAssignmentPair = [Number(blockerId), attackerId];
merged.set(pair.join(":"), pair);
}
}
}
return merged;
return Array.from(merged.values());
}, [uiAssignments, engineAssignments]);
}
1 change: 0 additions & 1 deletion client/src/components/board/GroupedPermanent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,6 @@
const validBlockerIds = new Set(waitingFor.data.valid_blocker_ids);
const eligibleIds = group.ids.filter((id) =>
validBlockerIds.has(id)
&& !blockerAssignments.has(id)
&& (waitingFor.data.valid_block_targets[id]?.length ?? 0) > 0,
);
return eligibleIds.length > 0 ? { mode: "blockers", eligibleIds } : null;
Expand Down Expand Up @@ -175,7 +174,7 @@
}

return null;
}, [

Check warning on line 177 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,
combatClickHandler,
Expand Down
40 changes: 32 additions & 8 deletions client/src/components/board/__tests__/ActionButton.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -283,9 +283,7 @@ describe("ActionButton", () => {
});
});

it("gates Block with None on an unassigned must-block creature, independent of menace", () => {
// No block_requirements (menace) present, so incompleteBlockCount is 0 — the
// gate here is purely the engine-provided must-block constraint.
it("does not client-gate Block with None on an unassigned must-block creature", () => {
const wf: WaitingFor = {
type: "DeclareBlockers",
data: {
Expand All @@ -299,24 +297,50 @@ describe("ActionButton", () => {
useUiStore.setState({ selectedAttackers: [], blockerAssignments: new Map() });

render(<ActionButton />);
expect(screen.getByRole("button", { name: "Block with None" })).toBeDisabled();
expect(screen.getByRole("button", { name: "Block with None" })).toBeEnabled();
});

it("enables Confirm Blockers once the must-block creature is assigned", () => {
it("submits every selected blocker pair without client-side requirement gating", () => {
const wf: WaitingFor = {
type: "DeclareBlockers",
data: {
player: 0,
valid_blocker_ids: [100],
valid_block_targets: { "100": [200] },
valid_block_targets: { "100": [200, 201] },
blocker_constraints: { "100": { kind: "MustBlock" } },
},
};
useGameStore.setState({ gameState: createGameState(wf), waitingFor: wf, legalActions: [] });
useUiStore.setState({ selectedAttackers: [], blockerAssignments: new Map([[100, 200]]) });
useUiStore.setState({
selectedAttackers: [],
blockerAssignments: new Map([[100, new Set([200, 201])]]),
});

render(<ActionButton />);
expect(screen.getByRole("button", { name: "Confirm Blockers (1)" })).toBeEnabled();
const confirm = screen.getByRole("button", { name: "Confirm Blockers (2)" });
expect(confirm).toBeEnabled();
fireEvent.click(confirm);
expect(vi.mocked(dispatchAction)).toHaveBeenCalledWith({
type: "DeclareBlockers",
data: { assignments: [[100, 200], [100, 201]] },
});
});

it("clears a pending blocker when the engine supplies a new declaration prompt", () => {
render(<ActionButton />);

act(() => useUiStore.getState().combatClickHandler?.(100));
expect(screen.getByText("Select the attacker this blocker should defend against")).toBeInTheDocument();

const nextPrompt = blockerPrompt();
act(() => {
useGameStore.setState({
gameState: createGameState(nextPrompt),
waitingFor: nextPrompt,
});
});

expect(screen.queryByText("Select the attacker this blocker should defend against")).not.toBeInTheDocument();
});

it("shows blocker controls when turn decision controller differs from blocking player (issue #1199)", () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,38 +4,51 @@ import { filterVisibleBlockerPairs } from "../blockAssignmentVisibility.ts";

describe("filterVisibleBlockerPairs", () => {
it("keeps blocker pairs controlled by visible players", () => {
const pairs = new Map([
const pairs: [number, number][] = [
[10, 100],
[20, 200],
[30, 300],
]);
];
const objects = {
10: { controller: 1 },
20: { controller: 2 },
30: { controller: 3 },
};

expect([...filterVisibleBlockerPairs(pairs, objects, new Set([0, 2])).entries()]).toEqual([
expect(filterVisibleBlockerPairs(pairs, objects, new Set([0, 2]))).toEqual([
[20, 200],
]);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});

it("keeps all live split-mode opponent pairs when every opponent is visible", () => {
const pairs = new Map([
const pairs: [number, number][] = [
[10, 100],
[20, 200],
[30, 300],
]);
];
const objects = {
10: { controller: 1 },
20: { controller: 2 },
30: { controller: 3 },
};

expect([...filterVisibleBlockerPairs(pairs, objects, new Set([0, 1, 2, 3])).entries()]).toEqual([
expect(filterVisibleBlockerPairs(pairs, objects, new Set([0, 1, 2, 3]))).toEqual([
[10, 100],
[20, 200],
[30, 300],
]);
});

it("retains every pair when one visible blocker blocks multiple attackers", () => {
const pairs: [number, number][] = [
[10, 100],
[10, 200],
];
const objects = { 10: { controller: 2 } };

expect(filterVisibleBlockerPairs(pairs, objects, new Set([0, 2]))).toEqual([
[10, 100],
[10, 200],
]);
});
});
18 changes: 9 additions & 9 deletions client/src/components/board/blockAssignmentVisibility.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
import type { ObjectId, PlayerId } from "../../adapter/types.ts";

export type BlockerAssignmentPair = [ObjectId, ObjectId];

export function filterVisibleBlockerPairs(
pairs: Map<ObjectId, ObjectId>,
pairs: readonly BlockerAssignmentPair[],
objects: Record<string, { controller: PlayerId }> | null,
visiblePlayerIds: ReadonlySet<PlayerId>,
): Map<ObjectId, ObjectId> {
if (!objects) return pairs;
return new Map(
Array.from(pairs.entries()).filter(([blockerId]) => {
const blockerController = objects[String(blockerId)]?.controller;
return blockerController == null || visiblePlayerIds.has(blockerController);
}),
);
): BlockerAssignmentPair[] {
if (!objects) return [...pairs];
return pairs.filter(([blockerId]) => {
const blockerController = objects[String(blockerId)]?.controller;
return blockerController == null || visiblePlayerIds.has(blockerController);
Comment on lines +11 to +13

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Move assignment-visibility filtering into the engine.

Lines 11-13 derive which blocker assignments are visible from game-object controller data in the client. Expose viewer-filtered blocker pairs through the engine/adapters instead, so every transport renders the same authorized state.

As per coding guidelines and path instructions, the frontend must only render engine-provided state and must not calculate, filter, derive, or reinterpret game data.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@client/src/components/board/blockAssignmentVisibility.ts` around lines 11 -
13, Remove the client-side controller-based filtering from the
assignment-visibility logic around the blocker-pair filter. Add or use an
engine/adapter API that returns blocker pairs already filtered for the viewing
player, and have the frontend render that engine-provided collection directly
without inspecting objects, controllers, or visiblePlayerIds.

Sources: Coding guidelines, Path instructions

});
}
Loading
Loading