Skip to content

cluster: declare-attackers per-attacker target legality#4805

Open
Daedalus-Icarus wants to merge 4 commits into
phase-rs:mainfrom
Daedalus-Icarus:cluster/attack-condition-bugs
Open

cluster: declare-attackers per-attacker target legality#4805
Daedalus-Icarus wants to merge 4 commits into
phase-rs:mainfrom
Daedalus-Icarus:cluster/attack-condition-bugs

Conversation

@Daedalus-Icarus

@Daedalus-Icarus Daedalus-Icarus commented Jul 1, 2026

Copy link
Copy Markdown

Summary

Closes #4800

Fix declare-attackers legality to use per-attacker legal target sets end to end. This prevents the engine, AI, and client from proposing or submitting attack declarations that are globally plausible but illegal for a specific creature, addressing the #4800 / #4765 combat-declaration failure class.

Implementation method (required)

How was the engine/parser logic in this PR produced? Check exactly one:

  • Produced via the /engine-implementer pipeline (plan → review-plan → implement → review-impl → commit)
  • Not /engine-implementer — explain why below

If you did not use /engine-implementer, state why (e.g. frontend-only
change, docs/CI/tooling change, release chore, or a fix too small to warrant
the pipeline):

Focused bugfix implemented directly in the workspace. The main change was a surgical combat-legality fix plus the required client/AI plumbing to consume the new per-attacker declare-attackers data.

Note

Any change to crates/engine/ game logic — parser, effects, resolver,
targeting, rules behavior — is expected to go through /engine-implementer.
The "not used" box is for changes that genuinely fall outside that scope.

CR references

Touched / added logic and tests around:

  • CR 508.1a
  • CR 508.1b
  • CR 508.1c
  • CR 508.1d
  • CR 701.15b
  • CR 702.26b

Verification

  • cargo fmt --all
    • Passed.
  • pnpm --dir client exec vitest --run src/utils/__tests__/combat.test.ts src/components/controls/__tests__/AttackTargetPicker.test.tsx
    • Passed: 2 files, 5 tests.
  • cd client && pnpm run type-check
    • Passed.
  • cd client && pnpm exec eslint src/components/controls/AttackTargetPicker.tsx src/components/controls/__tests__/AttackTargetPicker.test.tsx src/utils/combat.ts src/components/board/ActionButton.tsx
    • Passed.
  • cargo test -p engine valid_attack_targets_by_attacker_filters_scoped_attack_lockouts --lib
    • Passed.
  • cargo test -p engine scoped_legality --lib
    • Passed.
  • cargo test --no-run -p manabrew-compat --features engine/proptest
    • Passed.

@gemini-code-assist gemini-code-assist Bot left a comment

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.

Code Review

This pull request introduces per-attacker legal attack target tracking to support complex combat restrictions in multiplayer games, updating both the Rust engine and the React frontend. The review feedback identifies several critical Rust compilation errors caused by passing owned values instead of references to Vec::contains. Additionally, the feedback highlights a logic bug in the TypeScript target fallback, an inefficient O(N) computation for single-attacker targets, and a style guide violation due to missing mandatory CR annotations on new combat rule functions.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +3584 to +3586
valid_attack_targets_by_attacker
.get(id)
.is_none_or(|targets| targets.contains(target))

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.

critical

[CRITICAL] Compilation error due to passing owned AttackTarget to contains. Evidence: crates/engine/src/ai_support/candidates.rs:3586.
Why it matters: Passing an owned AttackTarget to Vec::contains causes a compilation error because contains expects a reference.
Suggested fix: Pass a reference to target instead.

Suggested change
valid_attack_targets_by_attacker
.get(id)
.is_none_or(|targets| targets.contains(target))
valid_attack_targets_by_attacker
.get(id)
.is_none_or(|targets| targets.contains(&target))
References
  1. Do not replace Option::is_none_or with map_or(true, ...) in Rust codebases that support Rust 1.82.0 or newer, as this will trigger Clippy's unnecessary_map_or lint.

Comment on lines +3797 to +3800
let required_players: Vec<PlayerId> = must_attack_players_for_creature(state, obj)
.into_iter()
.filter(|player| attackable_players.contains(player))
.collect();

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.

critical

[CRITICAL] Compilation error due to passing owned PlayerId to contains. Evidence: crates/engine/src/game/combat.rs:3799.
Why it matters: Passing an owned PlayerId to Vec::contains causes a compilation error because contains expects a reference.
Suggested fix: Pass a reference to player instead.

Suggested change
let required_players: Vec<PlayerId> = must_attack_players_for_creature(state, obj)
.into_iter()
.filter(|player| attackable_players.contains(player))
.collect();
let required_players: Vec<PlayerId> = must_attack_players_for_creature(state, obj)
.into_iter()
.filter(|player| attackable_players.contains(&player))
.collect();

Comment on lines +3806 to +3810
if let AttackTarget::Player(defending_pid) = target {
if has_attackable_non_goading_player && goading_players.contains(defending_pid) {
return false;
}
}

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.

critical

[CRITICAL] Compilation error due to passing owned PlayerId to contains. Evidence: crates/engine/src/game/combat.rs:3807.
Why it matters: Passing an owned PlayerId to contains causes a compilation error because contains expects a reference.
Suggested fix: Pass a reference to defending_pid instead.

Suggested change
if let AttackTarget::Player(defending_pid) = target {
if has_attackable_non_goading_player && goading_players.contains(defending_pid) {
return false;
}
}
if let AttackTarget::Player(defending_pid) = target {
if has_attackable_non_goading_player && goading_players.contains(&defending_pid) {
return false;
}
}

if required_players.is_empty() {
return true;
}
matches!(target, AttackTarget::Player(pid) if required_players.contains(pid))

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.

critical

[CRITICAL] Compilation error due to passing owned PlayerId to contains. Evidence: crates/engine/src/game/combat.rs:3814.
Why it matters: Passing an owned PlayerId to contains causes a compilation error because contains expects a reference.
Suggested fix: Pass a reference to pid instead.

Suggested change
matches!(target, AttackTarget::Player(pid) if required_players.contains(pid))
matches!(target, AttackTarget::Player(pid) if required_players.contains(&pid))

Comment on lines +71 to +79
export function getValidAttackTargetsForAttacker(
state: GameState | null,
attackerId: ObjectId,
): AttackTarget[] {
if (!state) return [];
const wf = state.waiting_for;
if (wf.type !== "DeclareAttackers") return [];
return wf.data.valid_attack_targets_by_attacker?.[String(attackerId)] ?? wf.data.valid_attack_targets ?? [];
}

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.

high

[HIGH] Incorrect fallback for attackers with no legal targets. Evidence: client/src/utils/combat.ts:78.
Why it matters: When valid_attack_targets_by_attacker is present, an attacker missing from the map has zero legal targets, but falling back to global targets incorrectly allows illegal attacks.
Suggested fix: Only fall back to global targets if the map itself is undefined, otherwise return an empty array.

export function getValidAttackTargetsForAttacker(
  state: GameState | null,
  attackerId: ObjectId,
): AttackTarget[] {
  if (!state) return [];
  const wf = state.waiting_for;
  if (wf.type !== "DeclareAttackers") return [];
  if (wf.data.valid_attack_targets_by_attacker) {
    return wf.data.valid_attack_targets_by_attacker[String(attackerId)] ?? [];
  }
  return wf.data.valid_attack_targets;
}
References
  1. Avoid using unnecessary optional chaining (?.) or nullish coalescing (??) on properties that are already guaranteed to be defined through TypeScript type narrowing (e.g., after checking a discriminated union's type field).

Comment on lines +3851 to +3858
pub fn get_valid_attack_targets_for_attacker(
state: &GameState,
attacker_id: ObjectId,
) -> Vec<AttackTarget> {
get_valid_attack_targets_by_attacker(state)
.remove(&attacker_id)
.unwrap_or_default()
}

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.

high

[HIGH] Inefficient O(N) computation for a single attacker's targets. Evidence: crates/engine/src/game/combat.rs:3851.
Why it matters: Computing valid targets for all battlefield permanents just to retrieve one attacker's targets is highly inefficient and scales poorly.
Suggested fix: Compute targets directly for the requested attacker_id using the helper functions.

Suggested change
pub fn get_valid_attack_targets_for_attacker(
state: &GameState,
attacker_id: ObjectId,
) -> Vec<AttackTarget> {
get_valid_attack_targets_by_attacker(state)
.remove(&attacker_id)
.unwrap_or_default()
}
pub fn get_valid_attack_targets_for_attacker(
state: &GameState,
attacker_id: ObjectId,
) -> Vec<AttackTarget> {
let gates = CombatStaticGates::compute(state);
let broad_targets = get_valid_attack_targets(state);
let base_targets =
base_valid_attack_targets_for_attacker_gated(state, attacker_id, &broad_targets, &gates);
valid_attack_targets_for_attacker_from_base(state, attacker_id, &base_targets, &gates)
}

Comment on lines +3630 to +3634
fn attacker_is_generically_eligible_to_attack(
state: &GameState,
attacker_id: ObjectId,
gates: &CombatStaticGates,
) -> bool {

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.

medium

[MED] Missing mandatory CR annotations on new combat rule functions. Evidence: crates/engine/src/game/combat.rs:3630.
Why it matters: Every rules-touching line of engine code must carry a verified CR annotation to maintain strict fidelity to the MTG Comprehensive Rules.
Suggested fix: Add appropriate CR annotations (e.g., CR 508.1a, CR 508.1c, CR 508.1d) to the new helper functions.

References
  1. Rule R6: Every rules-touching line of engine code must carry a comment of the form CR : . (link)

@Daedalus-Icarus Daedalus-Icarus force-pushed the cluster/attack-condition-bugs branch from d51380a to 304ef8a Compare July 1, 2026 19:45

@matthewevans matthewevans left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[HIGH] Final declare-attackers validation still measures "if able" against global players instead of per-attacker legal targets. Evidence: crates/engine/src/game/combat.rs:2551. Why it matters: a goaded creature whose only non-goading opponent is excluded by a scoped CantAttack restriction is surfaced by get_valid_attack_targets_by_attacker as able to attack its goader, but declare_attackers rejects that legal declaration because another player is globally attackable; the same global check at crates/engine/src/game/combat.rs:2575 affects directed MustAttackPlayer. Suggested fix: drive goad and directed-must enforcement from the same per-attacker base target set used by valid_attack_targets_for_attacker_from_base, and add fixtures combining scoped CantAttack with goad and MustAttackPlayer.

[HIGH] AttackTargetPicker "Attack All" still submits targets that are not common to every selected attacker. Evidence: client/src/components/controls/AttackTargetPicker.tsx:165. Why it matters: when attacker A can only attack P2 while attacker B can attack P1/P2, the picker opens and still offers global P1 in all mode; choosing it submits A -> P1, reintroducing illegal client declarations instead of rendering engine-provided per-attacker legality. Suggested fix: render only commonTargets in all/bulk modes.

@matthewevans matthewevans left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Reviewed current head a27771d. The backend blocker is fixed: final declare-attackers validation now uses the per-attacker legal target set for goad / must-attack checks, and Attack All is constrained to common per-attacker targets.

I am leaving final acceptance deferred because this PR includes frontend/client changes and is not by an FE-review-allowed author in the current sweep policy. Matt should handle the frontend acceptance path directly.

@matthewevans

matthewevans commented Jul 1, 2026

Copy link
Copy Markdown
Member

Backend re-reviewed at current head 47443c1. The only backend changes since my prior backend pass are compile/reference fixes in candidates.rs, combat.rs, and a test payload update in manabrew-compat; I do not see a new backend blocker.

Still holding final acceptance under the current sweep policy because this PR includes frontend/client changes and is not by an FE-review-allowed author. No approval/enqueue from the sweep.

@github-actions

github-actions Bot commented Jul 1, 2026

Copy link
Copy Markdown

Parse changes introduced by this PR

Baseline pending for acd2f5e6b08303d525d06074f6022ade155e3d7a — this populates once main publishes its coverage snapshot (a few minutes after that commit landed).

@matthewevans

Copy link
Copy Markdown
Member

Backend status at current head 2a43f89: no new backend changes since the prior backend re-review. The new commit only changes AttackTargetPicker.tsx and client/pnpm-lock.yaml, so I am keeping the backend-clean status from the previous sweep and continuing to defer final acceptance to Matt for the frontend/client surface.

@Daedalus-Icarus Daedalus-Icarus force-pushed the cluster/attack-condition-bugs branch from 2a43f89 to 10830eb Compare July 2, 2026 04:17
@matthewevans

Copy link
Copy Markdown
Member

Backend status on current head 10830eb07d026e32d6b6dcb0f3b5a38fb8f160d4: the new backend/AI test fixture changes after my prior backend pass are compile/fixture updates for the new valid_attack_targets_by_attacker field. I traced the candidate generator contract on this head: missing per-attacker entries fall back to the global valid_attack_targets, so the HashMap::new() additions preserve the legacy fixture semantics rather than changing the exercised target branch.

I am still not approving/enqueueing this from the sweep: this PR remains mixed backend/frontend and currently reports DIRTY, so final frontend/client acceptance and conflict handling stay deferred to Matt.

@Daedalus-Icarus Daedalus-Icarus force-pushed the cluster/attack-condition-bugs branch from 10830eb to a0e5a88 Compare July 2, 2026 04:41
@matthewevans matthewevans added the defer-fe Frontend/client/UI PR deferred to Matt's direct review label Jul 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

defer-fe Frontend/client/UI PR deferred to Matt's direct review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Cluster: Combat participation and attack-condition bugs (July 1 Discord batch)

2 participants