From d9a02366af706db97a22268d5933c4ccf2e5b833 Mon Sep 17 00:00:00 2001 From: matthewevans Date: Fri, 24 Jul 2026 16:29:25 -0700 Subject: [PATCH 01/10] fix(engine): enforce event-bound forced blocks --- client/src/adapter/types.ts | 2 +- client/src/components/board/ActionButton.tsx | 67 +- .../components/board/BlockAssignmentLines.tsx | 27 +- .../src/components/board/GroupedPermanent.tsx | 1 - .../board/__tests__/ActionButton.test.tsx | 23 +- .../__tests__/BlockAssignmentLines.test.ts | 12 +- .../board/blockAssignmentVisibility.ts | 18 +- .../combat/BlockerConstraintBadges.tsx | 5 + .../__tests__/combatRequirements.test.tsx | 29 +- .../components/combat/useBlockRequirements.ts | 20 +- .../combat/useBlockerConstraints.ts | 29 +- client/src/hooks/useRafPositions.ts | 17 +- client/src/i18n/locales/en/game.json | 1 + client/src/stores/__tests__/uiStore.test.ts | 19 +- client/src/stores/uiStore.ts | 37 +- .../__tests__/blockerSorting.test.ts | 33 +- client/src/viewmodel/blockerSorting.ts | 8 +- crates/engine/src/ai_support/candidates.rs | 33 +- crates/engine/src/database/synthesis.rs | 6 +- crates/engine/src/game/ability_rw.rs | 5 +- crates/engine/src/game/ability_scan.rs | 16 +- crates/engine/src/game/ability_utils.rs | 2 +- crates/engine/src/game/combat.rs | 951 ++++++++++++------ crates/engine/src/game/combat_damage.rs | 1 + crates/engine/src/game/coverage.rs | 4 +- .../src/game/effects/additional_phase.rs | 1 + crates/engine/src/game/effects/double.rs | 1 + crates/engine/src/game/effects/extra_turn.rs | 1 + crates/engine/src/game/effects/force_block.rs | 176 +++- .../grant_extra_loyalty_activations.rs | 1 + .../engine/src/game/effects/player_counter.rs | 2 + .../src/game/effects/reverse_turn_order.rs | 1 + .../engine/src/game/effects/skip_next_step.rs | 1 + .../engine/src/game/effects/skip_next_turn.rs | 1 + crates/engine/src/game/effects/vote.rs | 7 + crates/engine/src/game/engine_combat.rs | 58 +- crates/engine/src/game/layers.rs | 11 + crates/engine/src/game/meld_tests.rs | 1 + crates/engine/src/game/stack.rs | 15 + crates/engine/src/game/triggers.rs | 244 +++++ .../src/parser/oracle_effect/imperative.rs | 79 +- crates/engine/src/parser/oracle_effect/mod.rs | 8 +- .../src/parser/oracle_effect/subject.rs | 2 +- .../engine/src/parser/oracle_effect/tests.rs | 45 +- crates/engine/src/parser/oracle_ir/ast.rs | 16 +- crates/engine/src/parser/oracle_nom/target.rs | 58 ++ crates/engine/src/parser/oracle_trigger.rs | 14 + .../engine/src/parser/oracle_trigger_tests.rs | 22 + crates/engine/src/parser/swallow_check.rs | 1 + crates/engine/src/types/ability.rs | 108 +- crates/engine/src/types/game_state.rs | 24 +- crates/engine/src/types/statics.rs | 7 +- crates/phase-ai/src/search.rs | 12 +- 53 files changed, 1715 insertions(+), 568 deletions(-) diff --git a/client/src/adapter/types.ts b/client/src/adapter/types.ts index ab1ff095f8..2c4fc78719 100644 --- a/client/src/adapter/types.ts +++ b/client/src/adapter/types.ts @@ -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[] }; diff --git a/client/src/components/board/ActionButton.tsx b/client/src/components/board/ActionButton.tsx index 2edb945120..cbfd25acde 100644 --- a/client/src/components/board/ActionButton.tsx +++ b/client/src/components/board/ActionButton.tsx @@ -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"; @@ -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"), @@ -152,23 +140,22 @@ export function ActionButton() { // 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); + } } } }, @@ -241,7 +228,7 @@ export function ActionButton() { function handleConfirmBlockers() { dispatchAction({ type: "DeclareBlockers", - data: { assignments: Array.from(blockerAssignments.entries()) }, + data: { assignments: blockerPairs }, }); } @@ -317,14 +304,14 @@ export function ActionButton() { {mode === "combat-blockers" && ( <> - {blockerAssignments.size > 0 ? ( + {blockerPairs.length > 0 ? ( <>