Skip to content

Expose read-only cast target preview over WASM#5472

Merged
matthewevans merged 3 commits into
phase-rs:mainfrom
mike-theDude:fix/issue-5467-legal-target-preview
Jul 10, 2026
Merged

Expose read-only cast target preview over WASM#5472
matthewevans merged 3 commits into
phase-rs:mainfrom
mike-theDude:fix/issue-5467-legal-target-preview

Conversation

@mike-theDude

Copy link
Copy Markdown
Collaborator

Model: codex-5
Thinking: high
Tier: Standard

Summary

  • add an engine-owned read-only helper that returns cast-time target slots for currently castable spells
  • expose legal_targets_for_castable_js(object_id) through engine-wasm
  • return [] for uncastable, untargeted, or target-ambiguous casts

Closes #5467

Anchored on

  • crates/engine/src/game/casting.rs:10595 — existing cast path builds Aura target slots with find_legal_targets
  • crates/engine/src/game/casting.rs:11191 — existing read-only castability helper clones/flushes state before inspecting target legality
  • crates/engine-wasm/src/lib.rs:1155 — existing WASM query delegates to engine logic and serializes with to_js

Gate A

./scripts/check-parser-combinators.sh

Exit status: 0
Output: no stdout

Verification

  • cargo fmt --all
  • cargo fmt --all -- --check
  • cargo clippy --all-targets -- -D warnings
  • cargo test -p engine legal_target_slots_for_castable_spell
  • cargo test -p engine-wasm

Notes

  • Tilt was not reachable via tilt get uiresource clippy; used the documented direct cargo fallback.
  • No parser or frontend files changed.

@mike-theDude mike-theDude added the ai-contribution PR opened via docs/AI-CONTRIBUTOR.md flow label Jul 10, 2026

@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 adds a read-only preview of cast-time target slots for currently castable spells, exposing it via WASM and implementing the core logic in the engine. The review feedback points out several violations of the repository style guide (R6) due to missing mandatory Magic Comprehensive Rules (CR) annotations across multiple rule-touching sections, including face/layout choices, casting variants, modal spells, Aura/Mutate targeting, kicker costs, and target slot building.

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 +11230 to +11235
if (cast_face_choice_offered_from_zone(state, obj)
&& alternative_spell_layout(obj).is_some())
|| cast_spell_face_choice_offered_from_zone(state, obj)
{
return Ok(Vec::new());
}

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] Missing mandatory CR annotation for face/layout choice checks. Under Repository Style Guide R6, every rules-touching line of engine code must carry a comment of the form CR <number>: <description>.

        // CR 712.7: A double-faced card has two faces. A player casting a double-faced card chooses which face they are casting.
        if (cast_face_choice_offered_from_zone(state, obj)
            && alternative_spell_layout(obj).is_some())
            || cast_spell_face_choice_offered_from_zone(state, obj)
        {
            return Ok(Vec::new());
        }
References
  1. Every rules-touching line of engine code must carry a comment of the form CR : . (link)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Addressed in fe004ca.

Added verified CR 715.3 / CR 720.3 / CR 712.11b annotations for Adventure, Omen, and modal DFC face-choice ambiguity before target selection.

Comment on lines +11238 to +11241
let choices = casting_variant_choice_set(state, player, object_id);
if choices.options.len() > 1 {
return Ok(Vec::new());
}

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] Missing mandatory CR annotation for casting variant choice checks. Under Repository Style Guide R6, every rules-touching line of engine code must carry a comment of the form CR <number>: <description>.

    // CR 601.2b: If the spell has alternative or additional costs, the player declares their intention to pay them.
    let choices = casting_variant_choice_set(state, player, object_id);
    if choices.options.len() > 1 {
        return Ok(Vec::new());
    }
References
  1. Every rules-touching line of engine code must carry a comment of the form CR : . (link)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Addressed in fe004ca.

Added the verified CR 601.2b annotation for alternative/additional cost variant choices before target selection.

Comment on lines +11247 to +11249
if prepared.modal.is_some() {
return Ok(Vec::new());
}

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] Missing mandatory CR annotation for modal spell checks. Under Repository Style Guide R6, every rules-touching line of engine code must carry a comment of the form CR <number>: <description>.

Suggested change
if prepared.modal.is_some() {
return Ok(Vec::new());
}
// CR 601.2b: If the spell is modal, the player declares the mode choice.
if prepared.modal.is_some() {
return Ok(Vec::new());
}
References
  1. Every rules-touching line of engine code must carry a comment of the form CR : . (link)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Addressed in fe004ca.

Added the verified CR 601.2b annotation for modal choices occurring before target selection.

Comment on lines +11269 to +11308
if obj.card_types.subtypes.iter().any(|s| s == "Aura") {
return Ok(obj
.keywords
.iter()
.find_map(|keyword| {
if let Keyword::Enchant(filter) = keyword {
Some(TargetSelectionSlot {
legal_targets: targeting::find_legal_targets(
state,
filter,
player,
prepared.object_id,
),
optional: false,
})
} else {
None
}
})
.filter(|slot| !slot.legal_targets.is_empty())
.into_iter()
.collect());
}

if obj.mutate_form.is_some() {
let legal = targeting::find_legal_targets(
state,
&mutate_target_filter(),
player,
prepared.object_id,
);
return Ok(if legal.is_empty() {
Vec::new()
} else {
vec![TargetSelectionSlot {
legal_targets: legal,
optional: 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.

medium

[MEDIUM] Missing mandatory CR annotations for Aura and Mutate targeting rules. Under Repository Style Guide R6, every rules-touching line of engine code must carry a comment of the form CR <number>: <description>.

    if obj.card_types.subtypes.iter().any(|s| s == "Aura") {
        // CR 303.4a: An Aura spell requires a target, which is defined by its enchant keyword ability.
        return Ok(obj
            .keywords
            .iter()
            .find_map(|keyword| {
                if let Keyword::Enchant(filter) = keyword {
                    Some(TargetSelectionSlot {
                        legal_targets: targeting::find_legal_targets(
                            state,
                            filter,
                            player,
                            prepared.object_id,
                        ),
                        optional: false,
                    })
                } else {
                    None
                }
            })
            .filter(|slot| !slot.legal_targets.is_empty())
            .into_iter()
            .collect());
    }

    if obj.mutate_form.is_some() {
        // CR 702.140b: As a mutating creature spell is cast, it requires a target as defined by its mutate ability.
        let legal = targeting::find_legal_targets(
            state,
            &mutate_target_filter(),
            player,
            prepared.object_id,
        );
        return Ok(if legal.is_empty() {
            Vec::new()
        } else {
            vec![TargetSelectionSlot {
                legal_targets: legal,
                optional: false,
            }]
        });
    }
References
  1. Every rules-touching line of engine code must carry a comment of the form CR : . (link)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Addressed in fe004ca.

Added verified CR 303.4a and CR 702.140a annotations for Aura and mutate target-slot previews. Used CR 702.140a rather than 702.140b because 702.140a is the rule that defines the mutating spell target.

Comment on lines +11317 to +11324
let has_kicker_cost = state
.objects
.get(&prepared.object_id)
.and_then(|obj| obj.additional_cost.as_ref())
.is_some_and(|additional| matches!(additional, AdditionalCost::Kicker { .. }));
if has_kicker_cost && requires_additional_cost_declaration_before_targets(&resolved) {
return Ok(Vec::new());
}

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] Missing mandatory CR annotation for kicker/additional cost declaration. Under Repository Style Guide R6, every rules-touching line of engine code must carry a comment of the form CR <number>: <description>.

Suggested change
let has_kicker_cost = state
.objects
.get(&prepared.object_id)
.and_then(|obj| obj.additional_cost.as_ref())
.is_some_and(|additional| matches!(additional, AdditionalCost::Kicker { .. }));
if has_kicker_cost && requires_additional_cost_declaration_before_targets(&resolved) {
return Ok(Vec::new());
}
// CR 601.2b: If the spell has alternative or additional costs, the player declares their intention to pay them before choosing targets.
let has_kicker_cost = state
.objects
.get(&prepared.object_id)
.and_then(|obj| obj.additional_cost.as_ref())
.is_some_and(|additional| matches!(additional, AdditionalCost::Kicker { .. }));
if has_kicker_cost && requires_additional_cost_declaration_before_targets(&resolved) {
return Ok(Vec::new());
}
References
  1. Every rules-touching line of engine code must carry a comment of the form CR : . (link)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Addressed in fe004ca.

Added the verified CR 601.2b annotation for target-dependent kicker/additional-cost declarations before target selection.

return Ok(Vec::new());
}

let mut target_slots = build_target_slots(state, &resolved)?;

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] Missing mandatory CR annotation for target slot building. Under Repository Style Guide R6, every rules-touching line of engine code must carry a comment of the form CR <number>: <description>.

    // CR 601.2c: The player announces their choice of an appropriate player, object, or zone for each target the spell requires.
    let mut target_slots = build_target_slots(state, &resolved)?;
References
  1. Every rules-touching line of engine code must carry a comment of the form CR : . (link)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Addressed in fe004ca.

Added the verified CR 601.2c annotation for enumerating required target slots after earlier casting choices are known.

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

Good shape overall — the architecture pillars are satisfied and I verified each of these rather than taking them on faith:

  • Read-only holds. legal_target_slots_for_castable_spell clones into a local simulated and every reused helper (prepare_spell_cast, build_target_slots, find_legal_targets, cap_distribution_target_slots) takes &GameState. The negative test asserting byte-identical serialized state after the call is non-vacuous.
  • engine-wasm is pure transport. It delegates, serializes with to_js, maps Err → NULL. Zero game logic. Correct.
  • The positive test discriminates. It builds a real {R} instant with DealDamage{target: Any}, adds mana, and asserts exactly one non-optional slot containing TargetRef::Player(PlayerId(1)) — it fails if the impl were stubbed to vec![], and it drives the real pipeline.
  • Reusing Vec<TargetSelectionSlot> rather than the issue's Vec<TargetRef> is the better call: the client already has that type from WaitingFor::TargetSelection, so no new serialized surface.

Two things need to change before this lands.

1. The preview diverges from the real cast for Splice / Emerge / Casualty

This is a rules-ordering issue, and the CR is explicit about it. CR 601.2b — modal choice and splice — is announced before CR 601.2c, the target announcement this helper previews. continue_with_prepared honors that ordering with three detours:

  • casting.rs:10722splice::eligible_splice_cards(...)
  • casting.rs:10747prepared.casting_variant == CastingVariant::Emerge
  • casting.rs:10878effective_casualty_additional_cost(...), which early-returns through begin_optional_cost_before_targets so the sacrifice prompt precedes target selection (it sits after build_target_slots textually, but still returns before any target is chosen)

The new preview path contains none of them — grep -ci 'splice\|emerge\|casualty' over the diff returns 0. So for a castable Splice/Emerge/Casualty spell it falls straight through to build_target_slots and returns base slots that the real cast would never offer at that moment. Splice is the sharpest case: per CR 702.47a it reveals a card and adds its text to the spell, so it can add targets the preview cannot know about. Casualty and Emerge defer a sacrifice whose resolution can change board-derived target legality.

Silently returning a different answer than the cast path for a sub-class it appears to handle is the failure mode to avoid. Minimum fix: return Vec::new() when any of the three conditions holds, matching the "must choose something first" contract. Better, if you're up for it: factor the shared aura/mutate/build-slots core out of continue_with_prepared so preview, the bool probe, and submit can't drift again — right now there are three parallel implementations of the same gate sequence.

2. Per-call GameState clone on an explicitly per-hover path

casting.rs (new fn):

let mut simulated = state.clone();
super::layers::flush_layers(&mut simulated);

Issue #5467 frames this as a hover/drag query, so this is N castable cards × repeated hovers × full-state clone + layer flush. This codebase has been bitten by clone storms on display paths before, and the amortization pattern already exists for exactly this: spell_has_legal_targets_with_probe (casting.rs:11190) takes probe: Option<&PriorityCastProbe>, and PriorityCastProbe::state() (casting.rs:476) hands back the already-flushed state so a host loop flushes once and probes many spells.

Please mirror it: add legal_target_slots_for_castable_spell_with_probe(state, player, object_id, probe: Option<&PriorityCastProbe>) and have the WASM host reuse one flushed probe across the hover loop. Performance on hot paths is a first-class review gate here, not a nit.

3. Missing CR annotations on the gate branches

The function carries only the top-level // CR 601.2c. The face/layout, variant, modal, Aura, Mutate, kicker, and X-defer branches each implement a rule and need annotating — repo convention treats this as mandatory, same as cargo fmt. The good news is these are already written and verified in the corresponding branches of continue_with_prepared (Aura targeting CR 115.1b, X-defer CR 601.2b, plus Mutate and kicker); copy them across rather than re-deriving. Gemini flagged six of these separately and it's right.

Not blocking — a design question for you

[] currently means five different things: uncastable, castable-with-no-targets, target-ambiguous, awaiting a face/modal choice, and X-defer. WASM adds a sixth encoding (null on state error). A caller can't distinguish "castable, nothing to highlight" from "not castable," and recovering that by cross-referencing get_legal_actions_for_viewer_js pushes inference into the display layer. This repo prefers a typed enum over a sentinel, so Option<Vec<TargetSelectionSlot>> (None = uncastable or needs a prior choice) would read more idiomatically. It matches the issue's requested shape as-is, so I'll leave the call to you — but you're touching this code anyway.

Anchors in your PR body check out (casting.rs:10595, casting.rs:11191, and the WASM export beside get_legal_actions_for_viewer_js), and CR 601.2c is verbatim correct at docs/MagicCompRules.txt:2461. Ping me when the Splice/Emerge/Casualty and probe items are in and I'll re-review promptly.

@matthewevans matthewevans added the enhancement New feature or request label Jul 10, 2026
@github-actions

Copy link
Copy Markdown

Parse changes introduced by this PR

✓ No card-parse changes detected.

@matthewevans

Copy link
Copy Markdown
Member

Re-reviewed at fe004ca. Item 3 is done, and done well — but items 1 and 2 are still open, so I'm leaving this as changes-requested rather than clearing it.

✅ Item 3 — CR annotations: resolved

I grepped every new citation against docs/MagicCompRules.txt rather than taking them on faith, and all seven land verbatim:

Citation Verified text
CR 715.3 adventurer card — play normally or as an Adventure
CR 720.3 omen card — cast normally or as an Omen
CR 712.11b modal DFC — caster chooses which face
CR 601.2b modal / alternative & additional cost announcement
CR 601.2c target announcement
CR 303.4a Aura spell requires a target defined by enchant
CR 702.140a mutate

Your reasoning for preferring CR 702.140a over 702.140b is right: 702.140a is the rule that defines the mutating spell's target, and 702.140b covers the resolution merge. That's the kind of citation-level care this repo wants.

❌ Item 1 — Splice / Emerge / Casualty divergence: not addressed

Unchanged at the current head. Over the added lines of the fe004ca diff:

splice: 0    emerge: 0    casualty: 0

continue_with_prepared still detours through splice::eligible_splice_cards, the CastingVariant::Emerge branch, and effective_casualty_additional_cost — all of which are CR 601.2b announcements that precede the CR 601.2c targets this helper previews. The preview path falls straight through to build_target_slots and will return base slots the real cast would never offer at that moment. Splice remains the sharpest case: per CR 702.47a it adds text to the spell, so it can add targets the preview cannot know about.

The minimum fix is unchanged — return Vec::new() when any of the three conditions holds, matching the "must choose something first" contract you already apply to face, variant, modal, and X.

❌ Item 2 — per-call GameState clone: not addressed

probe: 0

Extracting legal_target_slots_for_castable_spell_in_flushed_state is a genuinely good move and it's most of the way there — but it's private, and the only caller is the public entry point that still does:

let mut simulated = state.clone();
super::layers::flush_layers(&mut simulated);

engine-wasm's legal_targets_for_castable_js calls that cloning entry once per object_id, so a hover loop over N castable cards is still N full-state clones plus N layer flushes. Since you've already isolated the flushed-state core, the remaining step is small: make it take probe: Option<&PriorityCastProbe> (mirroring spell_has_legal_targets_with_probe at casting.rs:11190, with PriorityCastProbe::state() at casting.rs:476 handing back the already-flushed state) and expose it so the WASM host flushes once and probes many. Performance on hot display paths is a first-class gate here, not a nit.

Reinforcing the item 1 refactor

Small supporting datapoint for the "factor out the shared core" suggestion: this PR adds a third ResolvedAbility::new(Effect::Unimplemented { name: String::new(), .. }) placeholder alongside the two already on main (casting.rs:10568, casting.rs:11137). Constructing a fake effect to satisfy a type signature is a symptom of the same duplicated gate sequence, not a defect in your change — I'm not asking you to fix it, just noting it strengthens the case for extracting the shared aura/mutate/build-slots core so preview, the bool probe, and submit cannot drift apart again.


CI is green, the parse-diff sticky reports no card-parse changes, and the read-only and discrimination properties I verified last round still hold. Items 1 and 2 are the only things between this and an approval — ping me when they're in and I'll turn it around promptly.

@mike-theDude

Copy link
Copy Markdown
Collaborator Author

Addressed the remaining review feedback in face223.\n\nChanges:\n- return no preview for pre-target choice paths that can alter targets or legality: Splice, Emerge, Casualty, Replicate, and Offering\n- added a probe-aware preview helper plus a batched WASM export so callers can reuse one flushed priority snapshot across multiple castable cards\n- added regression tests for batched previews, Splice, and Casualty\n\nLocal verification:\n- cargo fmt --all -- --check\n- CARGO_TARGET_DIR=/tmp/phase-target-finisher-5472 cargo test -p engine legal_target_slots_for_castable\n- CARGO_TARGET_DIR=/tmp/phase-target-finisher-5472 cargo test -p engine-wasm\n- CARGO_TARGET_DIR=/tmp/phase-target-finisher-5472 cargo clippy --all-targets -- -D warnings

@mike-theDude
mike-theDude force-pushed the fix/issue-5467-legal-target-preview branch from face223 to 984f11a Compare July 10, 2026 13:19
@mike-theDude

Copy link
Copy Markdown
Collaborator Author

Rebased on latest origin/main; the review-feedback commit is now 984f11a. The content is the same follow-up described above, replayed on top of f4df6a4.

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

Approved

All three blockers from the previous round are resolved, and one of them was addressed more broadly than I asked for.

Blocker 1 — rules-ordering divergence: fixed

Splice, Emerge and Casualty no longer fall through to build_target_slots and return base slots that the real cast would never offer at that moment. CR 601.2b announces modal choice and splice before CR 601.2c announces targets, and the preview now respects that ordering rather than silently answering a different question than continue_with_prepared would answer.

Blocker 2 — per-call clone on an explicit hover path: fixed the right way

legal_target_slots_for_castable_spell_with_probe now takes a PriorityCastProbe, mirroring the existing spell_has_legal_targets_with_probe. A host loop can flush layers once and probe many spells instead of cloning a full GameState per hover. Performance on hot display paths is a first-class gate in this repo, and you treated it as one.

Blocker 3 — CR annotations: fixed, and then some

You covered Offering, Replicate, MDFC, Adventure and Omen detours beyond the branches I named. I grep-verified all fifteen citations against docs/MagicCompRules.txt; every one is real and apt. Worth calling out specifically: CR 303.4a is used correctly here, for an Aura spell requiring a target defined by its enchant ability, which is what that rule actually says.

Still holding from round one

Restating what I verified previously so the record is complete:

  • The function holds GameState read-only. The negative test asserts byte-identical serialized state after the call.
  • engine-wasm stays pure transport: it delegates and maps Err to NULL, with zero game logic.
  • The positive test drives the real pipeline. It builds a real {R} instant with DealDamage { target: Any } and asserts exactly one non-optional slot containing the opponent, so it would fail against a stubbed vec![].

Credit also for reusing Vec<TargetSelectionSlot> instead of inventing a new serialized surface. The client already has that type from WaitingFor::TargetSelection.

Forward-looking note (non-blocking)

There are now three parallel implementations of the same gate sequence: this preview, the bool probe, and submit. You bailed correctly in each rather than letting them drift, but the structural fix is to factor the shared build-slots core out of continue_with_prepared so the three cannot diverge again. Worth doing when someone next touches this file.

Twelve checks green.

@matthewevans
matthewevans added this pull request to the merge queue Jul 10, 2026
Merged via the queue into phase-rs:main with commit 02590ed Jul 10, 2026
12 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 enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

engine-wasm: read-only legal_targets_for_castable(object_id); find_legal_targets exists engine-side but isn't exported

3 participants