Skip to content

Fix #1747: skip GameObject clone on owner-zone filter fast path - #1748

Merged
matthewevans merged 5 commits into
phase-rs:mainfrom
Lobster-0429:fix/1747-perf-token-storm
Jun 1, 2026
Merged

Fix #1747: skip GameObject clone on owner-zone filter fast path#1748
matthewevans merged 5 commits into
phase-rs:mainfrom
Lobster-0429:fix/1747-perf-token-storm

Conversation

@Lobster-0429

Copy link
Copy Markdown
Contributor

Summary

Closes #1747Engine performance degrades severely on token-storm and complex-board turns.

The report identifies six bottlenecks. This PR lands the first and lowest-risk one — a provably behavior-identical win — and documents the rest as profiling-gated follow-ups (they're invasive refactors of correctness-critical hot paths that warrant benchmarks before landing).

Change — bottleneck #1: GameObject clone on owner-zone filter matching

matches_target_filter_in_owner_zone (filter.rs:680) cloned the entire GameObject on every call just to override one field (controller := owner) for owner-scoped (hand / library / graveyard) filter evaluation — allocating the name String, the counters HashMap, and several Vecs per object. This runs once per scanned card on library scans for tutors/search effects (Diabolic Tutor, Cultivate).

Fix: when controller == owner — the overwhelmingly common case for objects in owner zones, where control-change effects almost never apply — the override is a no-op, so match against the borrowed object directly and skip the clone entirely. The clone path is retained for the rare controller != owner case.

Behavior is identical: the override only changes the result when controller != owner. A regression test exercises both paths and confirms CR 109.5 / CR 400.3 owner-scoping is preserved (a control-changed card in an owner zone still counts as its owner's).

Deferred follow-ups (the other five bottlenecks)

These are higher-risk refactors of hot, correctness-critical paths (triggers, layers, SBA) that should be validated with profiling/benchmarks before landing — out of scope for this minimal, safe PR:

  1. Arc<GameEvent> for trigger collection (triggers.rs:952+) — changes event storage type across the codebase.
  2. DifferentNameFrom caller-side HashSet memoization (filter.rs:2820) — the O(n²) is the per-outer-object rebuild; fixing it needs caller changes.
  3. Incremental layer-flush escalation (layers.rs:1300) — layer-system correctness.
  4. SBA single-scan-per-iteration (sba.rs:56) — SBA fixpoint correctness.
  5. NameMatchesAnyPermanent battlefield-only iteration (filter.rs:2492) — straightforward but reflows a wide match block; trivially safe, batched with the rest.

Notes

  • No Rust toolchain in this environment — CI is the verification (compile + the new regression test + the full suite confirm no behavior change).

🤖 Generated with Claude Code

…hase-rs#1747)

`matches_target_filter_in_owner_zone` cloned the full GameObject on every
call just to override `controller := owner` for owner-scoped (hand /
library / graveyard) filter matching — allocating `name`, the `counters`
HashMap, and several Vecs per object. This is hot on library scans for
tutors/search effects (Diabolic Tutor, Cultivate), where the clone runs
once per scanned card.

When `controller == owner` — the overwhelmingly common case for objects
in owner zones, where control-change effects almost never apply — the
override is a no-op, so the clone is pure waste. Add a fast path that
calls `filter_inner_for_object` against the borrowed object directly,
skipping the clone. Behavior is identical: the override only changes the
result when `controller != owner`, which still takes the clone path.

Adds a regression test exercising both paths (CR 109.5 / CR 400.3 owner
scoping preserved: a control-changed card in an owner zone still counts
as its owner's).

This is bottleneck #1 of the six in the report; the remaining items
(Arc<GameEvent> triggers, DifferentNameFrom memoization, layer-flush
escalation, SBA single-scan, NameMatchesAnyPermanent battlefield-only
iteration) are higher-risk refactors that warrant profiling/benchmarks
before landing and are deferred to follow-ups.

Closes phase-rs#1747

@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 a performance optimization in matches_target_filter_in_owner_zone by skipping the GameObject clone when the controller equals the owner, and adds a regression test to verify this behavior. The review feedback identifies a critical issue where the LKI cache lookup in effective_controller can bypass this owner-scoping override in non-battlefield zones, and suggests expanding the regression test to cover this LKI scenario.

// Vecs per call, which is hot on library scans for tutors/search effects).
// Behavior is identical: the override only changes the result when
// `controller != owner`.
if obj.controller == obj.owner {

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] LKI cache lookup in effective_controller defeats the owner-scoping override in matches_target_filter_in_owner_zone. Evidence: crates/engine/src/game/filter.rs:687.

Why it matters: When an object has an LKI entry in state.lki_cache (e.g., it recently died or changed zones), effective_controller will return lki.controller (the opponent's controller) instead of the overridden obj.controller (the owner), which violates CR 109.5 / CR 400.3 by letting control-change LKI exclude the owner in hand, library, and graveyard zones.

Suggested fix: Modify effective_controller (around line 601) to bypass the LKI cache lookup for hand, library, and graveyard zones:

fn effective_controller(state: &GameState, obj: &GameObject, object_id: ObjectId) -> PlayerId {
    if !matches!(obj.zone, Zone::Battlefield | Zone::Stack | Zone::Hand | Zone::Library | Zone::Graveyard) {
        if let Some(lki) = state.lki_cache.get(&object_id) {
            return lki.controller;
        }
    }
    obj.controller
}

Comment on lines +3882 to +3894
// Slow path: control-change the card to P1 (owner stays P0). Owner-
// scoping must still treat it as P0's card via the clone+override.
state.objects.get_mut(&card).unwrap().controller = PlayerId(1);
assert_ne!(
state.objects[&card].controller,
state.objects[&card].owner,
"precondition: slow path requires controller != owner"
);
assert!(
super::matches_target_filter_in_owner_zone(&state, card, &your_card, &ctx_p0),
"owner-scoping: a P1-controlled, P0-owned card in an owner zone is still P0's"
);
}

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

[MEDIUM] The new regression test does not cover the LKI cache scenario where the bug actually manifests. Evidence: crates/engine/src/game/filter.rs:3882.

Why it matters: Without an LKI cache entry in the test, the interaction between matches_target_filter_in_owner_zone and effective_controller is not exercised, leaving the correctness of owner-scoping unverified when LKI is present.

Suggested fix: Add an LKI cache entry with a different controller to the test to ensure owner-scoping is preserved even when LKI exists.

Lobster-0429 and others added 3 commits June 1, 2026 05:30
- bind CardId(state.next_object_id) before the &mut state borrow in
  create_object (E0502: cannot read state while mutably borrowed)
- keep both assert_eq!/assert_ne! comparison operands on one line per
  rustfmt
@matthewevans

Copy link
Copy Markdown
Member

Pushed maintainer review fixes on top of current main.

What changed:

  • Addressed Gemini's LKI finding by making controller lookup mode explicit in filter evaluation: normal matching still uses LKI for look-back effects, but owner-zone matching uses the owner-scoped live controller so stale lki_cache entries cannot override the owner substitution.
  • Extended the new owner-zone regression test with a conflicting LKI controller, so it covers the fast path and slow clone path under the exact failure mode.
  • Re-ran the existing LKI look-back regression to verify normal LiveOrLki behavior still works.

Local verification:

  • cargo fmt --all
  • git diff --check
  • CARGO_TARGET_DIR=/tmp/forge-rs-pr-1748-target cargo test -p engine owner_zone_filter_scopes_to_owner_on_fast_and_slow_paths --lib -- --nocapture
  • CARGO_TARGET_DIR=/tmp/forge-rs-pr-1748-target cargo test -p engine scoped_player_controller_uses_lki_for_exiled_objects --lib -- --nocapture

@matthewevans matthewevans added bug Bug fix ai-contribution PR opened via docs/AI-CONTRIBUTOR.md flow labels Jun 1, 2026
@matthewevans

Copy link
Copy Markdown
Member

Re-pushed after #1745 landed so this branch is current with origin/main. No additional implementation changes beyond the merge; focused owner-zone LKI test still passes locally on the refreshed head.

@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.

Implementation review is clean. The owner-zone fast path now avoids full GameObject clones without allowing stale LKI controller data to override owner-scoped matching; the existing LKI look-back behavior remains intact, targeted local tests passed, and CI is green on head 5729cd5.

@matthewevans
matthewevans added this pull request to the merge queue Jun 1, 2026
Merged via the queue into phase-rs:main with commit 76492a4 Jun 1, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ai-contribution PR opened via docs/AI-CONTRIBUTOR.md flow bug Bug fix

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Card Bug] Engine performance degrades severely on token-storm and complex-board turns

2 participants