Skip to content

feat(engine): derive targeting intent from the announcing effect - #6718

Merged
matthewevans merged 1 commit into
mainfrom
ship/engine-targeting-intent
Jul 28, 2026
Merged

feat(engine): derive targeting intent from the announcing effect#6718
matthewevans merged 1 commit into
mainfrom
ship/engine-targeting-intent

Conversation

@matthewevans

@matthewevans matthewevans commented Jul 28, 2026

Copy link
Copy Markdown
Member

Every board-target prompt the manabrew adapter emitted was labelled
TargetingIntent::Hostile — a hardcoded placeholder, contradicted by the
hostile: false sitting next to it. Buffing your own creature advertised
as an attack on it.

The earlier diagnosis blamed the protocol for having no neutral member.
That was wrong: the engine has the data. Effect is in hand when target
slots are built, so CR 601.2c's "one slot per announced target" can carry
what that target is for.

TargetSelectionSlot now stores the announcing link's EffectKind plus a
TargetEffectDetail for the two kinds whose unit tag is ambiguous — a
zone change's destination and a pump's direction, both read off the
payload at construction and unrecoverable afterwards. Stamping is
frame-scoped in collect_target_slots, mirroring current_chooser, so a
chained sub-ability does not inherit the head link's effect.

target_intent projects slot -> label at the interaction layer, leaving
GameState storing only game facts. The zone arm delegates to the
existing effect_zone_intent, so an EffectZoneChoice and a zone-change
target announcement cannot disagree about what a destination means.

Measured over the corpus, this labels 70.3% of targeting links, up from a
constant wrong answer. The residue is genuinely undirected pumps —
dynamic X and opposing "+2/-2" — which stay on the adverse label for the
same asymmetric-loss reason the neutral bucket does: a caution affordance
on a real buff is recoverable, marking "-4/-4" harmless is not.

Summary by CodeRabbit

  • New Features

    • Target selection opportunities now provide clearer intent labels, including damage, removal, combat, zone changes, attachment, and power/toughness effects.
    • Targeting information now distinguishes signed power/toughness increases and decreases and identifies relevant destination zones.
    • Compatibility output now reports targeting intent and hostility more accurately.
  • Bug Fixes

    • Corrected per-target effect attribution, improving interaction displays and targeting decisions.
  • Tests

    • Expanded coverage for effect attribution, intent mapping, hostility, and targeting scenarios.

Every board-target prompt the manabrew adapter emitted was labelled
`TargetingIntent::Hostile` — a hardcoded placeholder, contradicted by the
`hostile: false` sitting next to it. Buffing your own creature advertised
as an attack on it.

The earlier diagnosis blamed the protocol for having no neutral member.
That was wrong: the engine has the data. `Effect` is in hand when target
slots are built, so CR 601.2c's "one slot per announced target" can carry
what that target is for.

`TargetSelectionSlot` now stores the announcing link's `EffectKind` plus a
`TargetEffectDetail` for the two kinds whose unit tag is ambiguous — a
zone change's destination and a pump's direction, both read off the
payload at construction and unrecoverable afterwards. Stamping is
frame-scoped in `collect_target_slots`, mirroring `current_chooser`, so a
chained sub-ability does not inherit the head link's effect.

`target_intent` projects slot -> label at the interaction layer, leaving
`GameState` storing only game facts. The zone arm delegates to the
existing `effect_zone_intent`, so an `EffectZoneChoice` and a zone-change
target announcement cannot disagree about what a destination means.

Measured over the corpus, this labels 70.3% of targeting links, up from a
constant wrong answer. The residue is genuinely undirected pumps —
dynamic X and opposing "+2/-2" — which stay on the adverse label for the
same asymmetric-loss reason the neutral bucket does: a caution affordance
on a real buff is recoverable, marking "-4/-4" harmless is not.
@matthewevans
matthewevans enabled auto-merge July 28, 2026 04:14
@coderabbitai

coderabbitai Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Target-selection slots now carry effect kind and detail metadata. The engine projects that metadata into interaction intents, and the compatibility layer derives board-target intent and hostility from the projection. Tests and fixtures initialize the expanded slot structure.

Changes

Target intent pipeline

Layer / File(s) Summary
Effect metadata contracts and stamping
crates/engine/src/types/game_state.rs, crates/engine/src/game/ability_utils.rs, crates/engine/src/game/casting.rs
Target slots capture effect kinds, destinations, and power/toughness direction; collection and casting paths populate metadata per linked effect.
Interaction intent projection
crates/engine/src/types/interaction.rs, crates/engine/src/game/interaction.rs
New interaction intent variants map stamped target effects into target-sequence projections and selection presentation surfaces.
Board-target compatibility output
crates/manabrew-compat/src/lib.rs
Board-target prompts derive wire intent and hostility from the engine projection, with tests for damage, regeneration, pumps, and zone destinations.
Target-selection fixture alignment
crates/engine/src/ai_support/*, crates/engine/src/game/engine_trigger_target_tests.rs, crates/engine/src/game/visibility.rs, crates/phase-ai/src/**/*.rs
Existing test fixtures initialize effect_kind and effect_detail on target-selection slots.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant TargetSelectionState
  participant InteractionProjection
  participant ManabrewCompat
  TargetSelectionState->>InteractionProjection: project stamped target effect
  InteractionProjection->>ManabrewCompat: provide interaction intent
  ManabrewCompat->>ManabrewCompat: derive wire intent and hostility
  ManabrewCompat-->>TargetSelectionState: construct board-target prompt
Loading

Possibly related PRs

  • phase-rs/phase#6700: Both changes use the engine interaction projection to populate compatibility-layer targeting prompts.

Suggested labels: enhancement

Suggested reviewers: mike-thedude, ntindle, nickmopen

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately captures the main change: deriving targeting intent from the announcing effect in engine targeting logic.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch ship/engine-targeting-intent

Comment @coderabbitai help to get the list of available commands.

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

Actionable comments posted: 10

🧹 Nitpick comments (3)
crates/manabrew-compat/src/lib.rs (2)

4235-4245: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Choose is grouped under a comment that says these arms are unreachable — but Choose is the documented reachable fallback.

The registry entry and this function's own doc comment both describe Choose as the ~30% neutral population that does reach a CR 115.1 announcement. Folding it in with Keep/Crew/Saddle/Station/RingBearer/Blight/Pay under "these never reach a CR 115.1 target announcement" makes the one lossy, reachable arm invisible, and any code later added to this list silently inherits Hostile on a rationale that does not apply to it.

♻️ Split the reachable neutral arm from the unreachable ones
+        // Reachable and lossy: the neutral pick. `TargetingIntent` has no
+        // neutral member, so this falls back to `Hostile` — least-wrong, not
+        // safe. Declared as `local.targeting-intent-neutral-inexpressible`.
+        InteractionIntentCode::Choose => TargetingIntent::Hostile,
         // These never reach a CR 115.1 target announcement — they belong to the
         // board-selection and cost-payment models — but the match stays
         // exhaustive so a new intent code cannot silently fall into `Hostile`.
-        InteractionIntentCode::Choose
-        | InteractionIntentCode::Keep
+        InteractionIntentCode::Keep
         | InteractionIntentCode::Crew
🤖 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 `@crates/manabrew-compat/src/lib.rs` around lines 4235 - 4245, Separate
InteractionIntentCode::Choose from the grouped unreachable arms in the targeting
match. Handle Choose under its own comment and preserve its documented neutral
fallback behavior, while keeping Keep, Crew, Saddle, Station, RingBearer,
Blight, and Pay grouped as unreachable codes that map to
TargetingIntent::Hostile.

4156-4171: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

find_map over all opportunities silently picks a label when the crate elsewhere fails closed.

interaction_prompt and sole_open_opportunity both refuse when the viewer owns more than one opportunity (local.interaction-simultaneous-decisions-unmapped); this helper instead takes whichever Selection surface it finds first. For today's TargetSelection states that is one opportunity, so behaviour is unchanged — but the inconsistency means a future multi-slot case degrades into a wrong intent/hostile pair rather than a declared gap.

♻️ Match the single-opportunity posture used elsewhere
-    prepared
-        .interaction
-        .opportunities
-        .iter()
-        .find_map(|opportunity| {
-            opportunity.surfaces.iter().find_map(|surface| {
+    let [opportunity] = prepared.interaction.opportunities.as_slice() else {
+        return InteractionIntentCode::Choose;
+    };
+    opportunity
+        .surfaces
+        .iter()
+        .find_map(|surface| {
             if let InteractionPresentationSurface::Selection { intent, .. } = surface {
                 Some(*intent)
             } else {
                 None
             }
-            })
         })
         .unwrap_or(InteractionIntentCode::Choose)
🤖 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 `@crates/manabrew-compat/src/lib.rs` around lines 4156 - 4171, Update
projected_target_intent to fail closed unless the interaction contains exactly
one opportunity, matching interaction_prompt and sole_open_opportunity; only
inspect that opportunity’s Selection surface, and return the existing Choose
fallback for zero or multiple opportunities.
crates/engine/src/game/interaction.rs (1)

2748-2752: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚖️ Poor tradeoff

Wildcard _ over EffectKind conflicts with the repo's exhaustive-match rule.

The doc comment makes a reasonable case (231 variants, neutral fallback), but the practical consequence is that any newly added targeting effect silently ships as ChooseHostile at the protocol boundary, with no compiler signal. If the 231-arm match is genuinely unacceptable, consider moving the classification next to EffectKind as an exhaustive disposition() (grouping the unlabelled majority in one explicit arm list), so additions must opt in.

As per coding guidelines: "wildcard _ match arms where the enum is known and an exhaustive match would let the compiler catch missing variants" is a finding.

🤖 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 `@crates/engine/src/game/interaction.rs` around lines 2748 - 2752, The
EffectKind classification match currently uses a wildcard fallback that bypasses
exhaustive-match checks. Replace the `_ => InteractionIntentCode::Choose` arm in
the surrounding classification function with an exhaustive approach, preferably
by adding an exhaustive EffectKind disposition method that explicitly groups
neutral variants, and use that classification at the protocol boundary so newly
added effects require explicit handling.

Source: Coding guidelines

🤖 Prompt for all review comments with 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.

Inline comments:
In `@crates/engine/src/types/game_state.rs`:
- Around line 6185-6214: Add #[serde(default = "...")] to
TargetSelectionSlot::effect_kind, referencing a named default function that
returns the appropriate EffectKind. Define or reuse that named default alongside
the existing TargetEffectDetail default so persisted GameState values missing
effect_kind remain deserializable.

In `@crates/manabrew-compat/src/lib.rs`:
- Line 1413: Preserve the published capability code for the affected registry
entry by reverting the rename from local.targeting-intent-unsourceable to
local.targeting-intent-neutral-inexpressible, and update only its reason text as
needed. If retaining the new code, document an explicit exemption in the entry
explaining why the old code was never emitted or externally consumable.

In `@crates/phase-ai/src/policies/anti_self_harm.rs`:
- Around line 1127-1128: Replace the placeholder NoOp/None target-selection
metadata with the corresponding pending effect attribution across all listed
fixtures: in crates/phase-ai/src/policies/anti_self_harm.rs at 1127-1128 derive
it from make_target_selection_ctx’s effect, at 1772-1773 use the discard/draw
effect, at 1860-1861 the targeted discard effect, at 2732-2733 the rewind
ability, at 3003-3004 the aura target context or an explicitly modeled static
effect, at 3346-3347 the exile trigger, and at 3533-3534 the damage ability; in
crates/phase-ai/src/policies/combat_withdrawal.rs at 241-242 derive it from the
installed ability frame; and in
crates/phase-ai/src/policies/tests/removal_lethality.rs at 242-243 derive it
from the helper’s effect argument.

In `@crates/phase-ai/src/policies/context.rs`:
- Around line 286-287: Update the affected TargetSelectionSlot fixtures in the
Pump-effect cases to use the enclosing Effect::Pump’s concrete EffectKind and
TargetEffectDetail, including the fixed P/T direction, instead of
EffectKind::NoOp and TargetEffectDetail::None. Keep NoOp/None only for genuinely
effectless prompts, covering all listed occurrences.

In `@crates/phase-ai/src/policies/evasion_removal_priority.rs`:
- Around line 638-639: Update the TargetSelectionSlot entries in the enclosing
Destroy effect paths, including the occurrences around effect construction sites
at 638, 713, 781, and 849, so concrete targets use the Destroy effect’s actual
EffectKind and corresponding target-effect detail instead of EffectKind::NoOp
and TargetEffectDetail::None. Preserve NoOp metadata only for genuinely
non-operative targets.

In `@crates/phase-ai/src/policies/hand_disruption.rs`:
- Around line 447-448: Update the TargetSelectionSlot metadata at the affected
slots in the enclosing RevealHand abilities to use the enclosing ability’s
RevealHand effect kind instead of EffectKind::NoOp, while keeping effect_detail
as TargetEffectDetail::None and preserving the TargetSelectionSlot contract.

In `@crates/phase-ai/src/policies/recursion_awareness.rs`:
- Around line 199-200: Update the fixture effect metadata near the
pending-effect handling to derive effect_kind and effect_detail from each
pending effect instead of hardcoding EffectKind::NoOp and
TargetEffectDetail::None. Preserve Destroy for the first fixture and ChangeZone
with an Exile destination for the second, consistent with the
TargetSelectionSlot contract.

In `@crates/phase-ai/src/policies/stack_awareness.rs`:
- Around line 388-389: Update make_target_ctx to derive effect_kind and
effect_detail from its source_effect parameter using the engine’s authoritative
effect-to-kind/detail conversion, instead of always assigning EffectKind::NoOp
and TargetEffectDetail::None. Preserve the resulting metadata in
TargetSelectionSlot for Destroy, DealDamage, Bounce, and other effect types.

In `@crates/phase-ai/src/search.rs`:
- Around line 4494-4495: Update the TargetSelectionSlot metadata in
spell_target_selection_state so the slot describing the constructed
Effect::DealDamage uses EffectKind::DealDamage instead of NoOp, while preserving
the existing effect detail unless the TargetSelectionSlot contract requires it
to change.

In `@crates/phase-ai/src/tactical_gate.rs`:
- Around line 846-847: Update the three gate fixtures’ TargetSelectionSlot
effect metadata to match their pending effects: use the corresponding EffectKind
and TargetEffectDetail for Destroy, Bounce, and DealDamage instead of NoOp/None.
Apply the change at the fixture definitions around the visible
effect_kind/effect_detail fields, preserving the existing target-selection
setup.

---

Nitpick comments:
In `@crates/engine/src/game/interaction.rs`:
- Around line 2748-2752: The EffectKind classification match currently uses a
wildcard fallback that bypasses exhaustive-match checks. Replace the `_ =>
InteractionIntentCode::Choose` arm in the surrounding classification function
with an exhaustive approach, preferably by adding an exhaustive EffectKind
disposition method that explicitly groups neutral variants, and use that
classification at the protocol boundary so newly added effects require explicit
handling.

In `@crates/manabrew-compat/src/lib.rs`:
- Around line 4235-4245: Separate InteractionIntentCode::Choose from the grouped
unreachable arms in the targeting match. Handle Choose under its own comment and
preserve its documented neutral fallback behavior, while keeping Keep, Crew,
Saddle, Station, RingBearer, Blight, and Pay grouped as unreachable codes that
map to TargetingIntent::Hostile.
- Around line 4156-4171: Update projected_target_intent to fail closed unless
the interaction contains exactly one opportunity, matching interaction_prompt
and sole_open_opportunity; only inspect that opportunity’s Selection surface,
and return the existing Choose fallback for zero or multiple opportunities.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 0fae5c3b-aea9-48fc-a072-79499d3cac87

📥 Commits

Reviewing files that changed from the base of the PR and between e001db0 and 58bf811.

⛔ Files ignored due to path filters (1)
  • client/src/adapter/generated/interaction/index.ts is excluded by !**/generated/**
📒 Files selected for processing (22)
  • crates/engine/src/ai_support/candidates.rs
  • crates/engine/src/ai_support/combat_withdrawal.rs
  • crates/engine/src/ai_support/mod.rs
  • crates/engine/src/game/ability_utils.rs
  • crates/engine/src/game/casting.rs
  • crates/engine/src/game/engine_trigger_target_tests.rs
  • crates/engine/src/game/interaction.rs
  • crates/engine/src/game/visibility.rs
  • crates/engine/src/types/game_state.rs
  • crates/engine/src/types/interaction.rs
  • crates/manabrew-compat/src/lib.rs
  • crates/phase-ai/src/policies/anti_self_harm.rs
  • crates/phase-ai/src/policies/combat_withdrawal.rs
  • crates/phase-ai/src/policies/context.rs
  • crates/phase-ai/src/policies/evasion_removal_priority.rs
  • crates/phase-ai/src/policies/hand_disruption.rs
  • crates/phase-ai/src/policies/recursion_awareness.rs
  • crates/phase-ai/src/policies/stack_awareness.rs
  • crates/phase-ai/src/policies/tests/removal_lethality.rs
  • crates/phase-ai/src/search.rs
  • crates/phase-ai/src/tactical_gate.rs
  • crates/phase-ai/tests/scenarios.rs

Comment on lines +6185 to +6214
/// CR 115.1: The kind of effect that will affect this target — the game
/// fact of *what the spell or ability does to the thing being chosen*.
/// Stamped at slot construction from the enclosing ability frame's own
/// `Effect`, because the slot is otherwise attribution-free: nothing here
/// references the effect, so a consumer cannot recover it later without
/// re-walking the effect tree in lockstep with the slot builder.
///
/// This is deliberately the game fact and NOT a presentation intent.
/// Labelling (e.g. "this prompt is hostile") is a projection-layer
/// decision made by `target_intent` in `game::interaction`, mirroring how
/// `WaitingFor::EffectZoneChoice` stores `effect_kind` and lets
/// `effect_zone_intent` label it.
pub effect_kind: EffectKind,
/// CR 115.1: The discriminating fact that `effect_kind` does not carry.
///
/// `EffectKind` is a unit tag, so two effects that do opposite things to a
/// target can share one variant: `Effect::ChangeZone` is the same kind
/// whether it exiles or returns to hand, and `Effect::Pump` is the same
/// kind for "+3/+3" and "-3/-3". Both hold the deciding value in their
/// payload, which is in hand at slot construction and unrecoverable
/// afterwards.
///
/// One sum type rather than one `Option<T>` field per lossy kind: the axis
/// is "what extra fact does this kind need", and a per-kind field would be
/// the sibling-cluster smell at struct level. `From<&Effect> for
/// EffectKind` already reads payloads this way for `SetTapState`, so
/// payload discrimination at this boundary is established practice.
#[serde(default)]
pub effect_detail: TargetEffectDetail,
}

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 | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

Does serde's #[serde(default)] attribute require the field type to implement the Default trait, or does it accept a value literal directly?

💡 Result:

The #[serde(default)] attribute, when used without arguments on a field, requires that the field type implements the Default trait [1][2][3]. When the field is missing during deserialization, Serde will automatically call Default::default() to provide a value [1][2]. If you need a default value that is not provided by the Default trait, you must use the #[serde(default = "path")] syntax, where "path" refers to a function or method that returns the desired value [1][2][3]. This function must be callable as fn() -> T [1][4]. Serde does not support specifying a value literal (such as #[serde(default = 42)]) directly in the attribute [1][3][5]. While there have been community discussions and issues regarding support for literal values [5], the official mechanism remains limited to Default::default() or a custom function path [1][4].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate the target struct and nearby serde/migration helpers.
rg -n "pub effect_kind: EffectKind|TargetSelectionSlot|#[[:space:]]*serde\\(default" crates/engine/src/types/game_state.rs

# Show the relevant section with line numbers.
sed -n '6170,6235p' crates/engine/src/types/game_state.rs

# Check whether EffectKind has a Default impl or an existing default helper.
rg -n "enum EffectKind|impl Default for EffectKind|fn default_.*effect_kind|EffectKind::NoOp" crates/engine/src/types -g '*.rs'

Repository: phase-rs/phase

Length of output: 4485


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the surrounding waiting-state enums and any custom serde handling.
sed -n '3400,4485p' crates/engine/src/types/game_state.rs

# Find custom deserialize/default hooks related to game state / waiting states.
rg -n "deserialize_with|default *= *\"|#[[:space:]]*serde\\(.*default|impl .*Deserialize.*GameState|enum WaitingFor|TargetSelectionSlot" crates/engine/src/types/game_state.rs crates/engine/src/types -g '*.rs'

Repository: phase-rs/phase

Length of output: 50370


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Check whether EffectKind implements Default and whether GameState has custom serde.
rg -n "impl Default for EffectKind|#[[:space:]]*derive\\([^\\)]*Default[^\\)]*\\).*EffectKind|enum EffectKind|impl<'de> Deserialize<'de> for GameState|struct GameState|enum WaitingFor|TargetSelectionSlot" crates/engine/src/types/ability.rs crates/engine/src/types/game_state.rs -g '*.rs'

# Show the WaitingFor definitions and the target-selection variants around them.
sed -n '4380,4475p' crates/engine/src/types/game_state.rs

# Show the EffectKind enum declaration.
sed -n '16300,16640p' crates/engine/src/types/ability.rs

Repository: phase-rs/phase

Length of output: 16653


Add a serde default for TargetSelectionSlot::effect_kind. Persisted GameState values that park at target selection will fail to deserialize once this key is missing; use a named default function here to match effect_detail and keep saved in-flight states loadable.

🤖 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 `@crates/engine/src/types/game_state.rs` around lines 6185 - 6214, Add
#[serde(default = "...")] to TargetSelectionSlot::effect_kind, referencing a
named default function that returns the appropriate EffectKind. Define or reuse
that named default alongside the existing TargetEffectDetail default so
persisted GameState values missing effect_kind remain deserializable.

},
UnsupportedCapability {
code: "local.targeting-intent-unsourceable",
code: "local.targeting-intent-neutral-inexpressible",

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 | ⚡ Quick win

Renaming a published capability code contradicts this registry's own stated policy.

The upstream.multi-destination-partition-missing entry justifies keeping a misleading code precisely because "renaming a published capability code is itself a contract break". A client pinned to local.targeting-intent-unsourceable now looks it up and finds nothing. Either keep the old code and update only reason, or state explicitly here why this one is exempt (e.g. never emitted at a call site, so no client can hold it).

🤖 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 `@crates/manabrew-compat/src/lib.rs` at line 1413, Preserve the published
capability code for the affected registry entry by reverting the rename from
local.targeting-intent-unsourceable to
local.targeting-intent-neutral-inexpressible, and update only its reason text as
needed. If retaining the new code, document an explicit exemption in the entry
explaining why the old code was never emitted or externally consumable.

Comment on lines +1127 to +1128
effect_kind: EffectKind::NoOp,
effect_detail: TargetEffectDetail::None,

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 | ⚡ Quick win

Preserve effect attribution across all target-selection fixtures.

These fixtures pair concrete pending effects with TargetSelectionSlot { effect_kind: NoOp, effect_detail: None }, creating state that does not match the engine contract and can conceal incorrect target-intent projections.

  • crates/phase-ai/src/policies/anti_self_harm.rs#L1127-L1128: derive metadata from make_target_selection_ctx’s effect.
  • crates/phase-ai/src/policies/anti_self_harm.rs#L1772-L1773: stamp metadata for the discard/draw effect.
  • crates/phase-ai/src/policies/anti_self_harm.rs#L1860-L1861: stamp metadata for the targeted discard effect.
  • crates/phase-ai/src/policies/anti_self_harm.rs#L2732-L2733: stamp metadata for the rewind ability.
  • crates/phase-ai/src/policies/anti_self_harm.rs#L3003-L3004: stamp metadata for the aura target context or explicitly model its static effect.
  • crates/phase-ai/src/policies/anti_self_harm.rs#L3346-L3347: stamp metadata for the exile trigger.
  • crates/phase-ai/src/policies/anti_self_harm.rs#L3533-L3534: stamp metadata for the damage ability.
  • crates/phase-ai/src/policies/combat_withdrawal.rs#L241-L242: derive metadata from the installed ability frame.
  • crates/phase-ai/src/policies/tests/removal_lethality.rs#L242-L243: derive metadata from the helper’s effect argument.
📍 Affects 3 files
  • crates/phase-ai/src/policies/anti_self_harm.rs#L1127-L1128 (this comment)
  • crates/phase-ai/src/policies/anti_self_harm.rs#L1772-L1773
  • crates/phase-ai/src/policies/anti_self_harm.rs#L1860-L1861
  • crates/phase-ai/src/policies/anti_self_harm.rs#L2732-L2733
  • crates/phase-ai/src/policies/anti_self_harm.rs#L3003-L3004
  • crates/phase-ai/src/policies/anti_self_harm.rs#L3346-L3347
  • crates/phase-ai/src/policies/anti_self_harm.rs#L3533-L3534
  • crates/phase-ai/src/policies/combat_withdrawal.rs#L241-L242
  • crates/phase-ai/src/policies/tests/removal_lethality.rs#L242-L243
🤖 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 `@crates/phase-ai/src/policies/anti_self_harm.rs` around lines 1127 - 1128,
Replace the placeholder NoOp/None target-selection metadata with the
corresponding pending effect attribution across all listed fixtures: in
crates/phase-ai/src/policies/anti_self_harm.rs at 1127-1128 derive it from
make_target_selection_ctx’s effect, at 1772-1773 use the discard/draw effect, at
1860-1861 the targeted discard effect, at 2732-2733 the rewind ability, at
3003-3004 the aura target context or an explicitly modeled static effect, at
3346-3347 the exile trigger, and at 3533-3534 the damage ability; in
crates/phase-ai/src/policies/combat_withdrawal.rs at 241-242 derive it from the
installed ability frame; and in
crates/phase-ai/src/policies/tests/removal_lethality.rs at 242-243 derive it
from the helper’s effect argument.

Comment on lines +286 to +287
effect_kind: EffectKind::NoOp,
effect_detail: TargetEffectDetail::None,

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 | ⚡ Quick win

Preserve the concrete Pump attribution in these fixtures.

Each slot is paired with a pending Effect::Pump, but is stamped as EffectKind::NoOp. Since interaction projection derives target intent from slot metadata, these fixtures can mask incorrect Pump classification. Populate the kind/detail from the enclosing Pump effect, including its fixed P/T direction; reserve NoOp for genuinely effectless prompts.

Based on the PR objective and the TargetSelectionSlot contract.

Also applies to: 351-352, 567-568, 618-619, 670-671

🤖 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 `@crates/phase-ai/src/policies/context.rs` around lines 286 - 287, Update the
affected TargetSelectionSlot fixtures in the Pump-effect cases to use the
enclosing Effect::Pump’s concrete EffectKind and TargetEffectDetail, including
the fixed P/T direction, instead of EffectKind::NoOp and
TargetEffectDetail::None. Keep NoOp/None only for genuinely effectless prompts,
covering all listed occurrences.

Comment on lines +638 to +639
effect_kind: EffectKind::NoOp,
effect_detail: TargetEffectDetail::None,

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 | ⚡ Quick win

Do not label concrete Destroy targets as NoOp.

These target slots are used with Effect::Destroy, so EffectKind::NoOp is incorrect game-state metadata. Populate the slot from the enclosing Destroy effect so target-intent projection and future consumers see the real operation.

Based on the PR objective and the TargetSelectionSlot contract.

Also applies to: 713-714, 781-782, 849-850

🤖 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 `@crates/phase-ai/src/policies/evasion_removal_priority.rs` around lines 638 -
639, Update the TargetSelectionSlot entries in the enclosing Destroy effect
paths, including the occurrences around effect construction sites at 638, 713,
781, and 849, so concrete targets use the Destroy effect’s actual EffectKind and
corresponding target-effect detail instead of EffectKind::NoOp and
TargetEffectDetail::None. Preserve NoOp metadata only for genuinely
non-operative targets.

Comment on lines +447 to +448
effect_kind: EffectKind::NoOp,
effect_detail: TargetEffectDetail::None,

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 | ⚡ Quick win

Preserve RevealHand attribution in the target slots.

These slots belong to concrete Effect::RevealHand abilities, but the new metadata says NoOp. Populate the effect kind from the enclosing ability instead of using the neutral sentinel, otherwise projection tests can miss incorrect RevealHand intent handling.

Based on the PR objective and the TargetSelectionSlot contract.

Also applies to: 576-577, 668-669

🤖 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 `@crates/phase-ai/src/policies/hand_disruption.rs` around lines 447 - 448,
Update the TargetSelectionSlot metadata at the affected slots in the enclosing
RevealHand abilities to use the enclosing ability’s RevealHand effect kind
instead of EffectKind::NoOp, while keeping effect_detail as
TargetEffectDetail::None and preserving the TargetSelectionSlot contract.

Comment on lines +199 to +200
effect_kind: EffectKind::NoOp,
effect_detail: TargetEffectDetail::None,

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 | ⚡ Quick win

Retain the actual removal operation and destination.

The first fixture targets with Destroy; the second targets with ChangeZone to Exile. Both are currently stamped as NoOp/None, losing the exact facts required for target-intent projection. Derive the metadata from each pending effect, including the Exile destination.

Based on the PR objective and the TargetSelectionSlot contract.

Also applies to: 293-294

🤖 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 `@crates/phase-ai/src/policies/recursion_awareness.rs` around lines 199 - 200,
Update the fixture effect metadata near the pending-effect handling to derive
effect_kind and effect_detail from each pending effect instead of hardcoding
EffectKind::NoOp and TargetEffectDetail::None. Preserve Destroy for the first
fixture and ChangeZone with an Exile destination for the second, consistent with
the TargetSelectionSlot contract.

Comment on lines +388 to +389
effect_kind: EffectKind::NoOp,
effect_detail: TargetEffectDetail::None,

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 | ⚡ Quick win

Derive slot metadata from source_effect.

make_target_ctx already receives the concrete effect used to build PendingCast, but the slot is always stamped NoOp/None. That makes Destroy, DealDamage, and Bounce fixtures internally contradictory. Use the engine’s authoritative effect-to-kind/detail conversion here so every caller receives accurate metadata.

Based on the PR objective and the TargetSelectionSlot contract.

🤖 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 `@crates/phase-ai/src/policies/stack_awareness.rs` around lines 388 - 389,
Update make_target_ctx to derive effect_kind and effect_detail from its
source_effect parameter using the engine’s authoritative effect-to-kind/detail
conversion, instead of always assigning EffectKind::NoOp and
TargetEffectDetail::None. Preserve the resulting metadata in TargetSelectionSlot
for Destroy, DealDamage, Bounce, and other effect types.

Comment on lines +4494 to +4495
effect_kind: EffectKind::NoOp,
effect_detail: engine::types::game_state::TargetEffectDetail::None,

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 | ⚡ Quick win

Stamp the damage target slot as DealDamage.

spell_target_selection_state constructs an Effect::DealDamage, but its slot claims NoOp. Populate the metadata from that effect so this production search-path fixture does not bypass target-intent attribution.

Based on the PR objective and the TargetSelectionSlot contract.

🤖 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 `@crates/phase-ai/src/search.rs` around lines 4494 - 4495, Update the
TargetSelectionSlot metadata in spell_target_selection_state so the slot
describing the constructed Effect::DealDamage uses EffectKind::DealDamage
instead of NoOp, while preserving the existing effect detail unless the
TargetSelectionSlot contract requires it to change.

Comment on lines +846 to +847
effect_kind: EffectKind::NoOp,
effect_detail: TargetEffectDetail::None,

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 | ⚡ Quick win

Keep the gate fixtures’ effect metadata truthful.

The three fixtures use concrete Destroy, Bounce, and DealDamage effects, but all slots are marked NoOp/None. Derive the corresponding kind/detail from each pending effect so target-selection state remains consistent with the operation being evaluated.

Based on the PR objective and the TargetSelectionSlot contract.

Also applies to: 919-920, 974-975

🤖 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 `@crates/phase-ai/src/tactical_gate.rs` around lines 846 - 847, Update the
three gate fixtures’ TargetSelectionSlot effect metadata to match their pending
effects: use the corresponding EffectKind and TargetEffectDetail for Destroy,
Bounce, and DealDamage instead of NoOp/None. Apply the change at the fixture
definitions around the visible effect_kind/effect_detail fields, preserving the
existing target-selection setup.

@matthewevans
matthewevans added this pull request to the merge queue Jul 28, 2026
@github-actions

Copy link
Copy Markdown

Parse changes introduced by this PR

✓ No card-parse changes detected.

Merged via the queue into main with commit 0468df1 Jul 28, 2026
19 checks passed
@matthewevans
matthewevans deleted the ship/engine-targeting-intent branch July 28, 2026 04:45
minion1227 added a commit to minion1227/phase that referenced this pull request Jul 28, 2026
Resolves the conflict introduced by phase-rs#6718 (derive targeting intent from
the announcing effect), which landed after this branch's previous merge.

The only collision is the `types::ability` import list in
`crates/engine/src/game/ability_utils.rs`: this branch adds
`AnnouncedModalChoice`, phase-rs#6718 adds `EffectKind` and `PtValue`. Resolved as
the union of both — no logic conflict.

The two changes compose at different stages of the announcement pipeline,
so neither bypasses the other:

  - This branch's `resolve_legal_modal_choice` decides which modes are
    legal when the ability is put on the stack (CR 700.2b / CR 603.3c).
  - phase-rs#6718 stamps `EffectKind` onto target slots in `collect_target_slots`,
    which runs after a mode has been chosen.

Verified on the merged tree: `cargo check -p engine -p phase-ai
--all-targets` is clean, and both round-10 regressions still pass
(`prompt_cap_equals_modal_choice_authority`,
`prompt_unavailable_modes_equal_modal_choice_authority`).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@lgray

lgray commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

🤖 AI text below 🤖

Save-compatibility regression, measured: this PR added pub effect_kind: EffectKind to TargetSelectionSlot without #[serde(default)], so a persisted save paused at a target-selection prompt now fails to deserialize with missing field `effect_kind` . (effect_detail, added in the same hunk, did get the default.)

Measured repro on a real pre-#6718 4p dump saved at waiting_for: TriggerTargetSelection:

cargo test -p engine --test integration -- \
    loop_shortcut::probe_persisted_path_vs_direct_gamestate_on_dellian --nocapture
DIRECT_OK=false     DIRECT_ERR=Some("missing field `effect_kind`")
PERSISTED_OK=false  PERSISTED_ERR=Some("missing field `effect_kind`")

Direct GameState decode and the production PersistedGameStatedecode_persisted_resolution_state (Raw/historical-snapshot branch) fail identically on the same serde_json::Value. Scope: only the Raw branch is measured; the Trusted envelope branch reads as failing the same way structurally, but that is a reading, not a measurement. We deliberately did not synthesize a Trusted envelope — one lacking the resolution_state_version marker would fail on a missing discriminator, a different error wearing this finding's clothes.

Why CI stayed green: no tracked fixture upstream (or in our twelve, save one) pauses at a target-selection prompt, so the suite structurally cannot reach this decode.

The fix is a genuine tradeoff, flagged for your call rather than PR'd: (1) #[serde(default)] — legacy saves load, with degraded prompt labelling on pre-#6718 prompts (per the field's own doc, labelling is a projection-layer decision, so not a rules error); (2) a migration in decode_persisted_resolution_state deriving effect_kind from the pending trigger's announcing effect — fully derivable in our measured case; (3) accept and document the break. We're migrating our own fixtures either way (scripted derivation, happy to share), and we haven't touched types/game_state.rs. A minimal regression fixture paused at a target prompt would close the structural blind spot.

@matthewevans

Copy link
Copy Markdown
Member Author

@lgray I'm hesitant to commit to patching the serialization for loading legacy saves. at this point in this the project I'm of the opinion that means "alpha means it may not load 🤷‍♂️"

if you have a use-case then do the save changes locally, but I'm weary of accumulating debt for loading legacy saves and/or being beholden to that as a feature (at this point)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants