Add Lady Loki, Agent of Chaos - #6945
Conversation
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (6)
🚧 Files skipped from review as they are similar to previous changes (5)
📝 WalkthroughWalkthroughThe change adds target-aware quantity resolution, parses qualified target card references, and preserves parent object targets through paused damage continuations. New tests cover Lady Loki’s parser and runtime behavior. ChangesTarget-scoped damage resolution
Estimated code review effort: 4 (Complex) | ~45 minutes Sequence Diagram(s)sequenceDiagram
participant TriggeredSpell
participant LadyLokiAbility
participant ExileUntilEffect
participant DamageEachPlayer
participant QuantityResolver
participant CastFromZone
TriggeredSpell->>LadyLokiAbility: trigger on first matching spell
LadyLokiAbility->>ExileUntilEffect: exile cards until a nonland card
ExileUntilEffect->>DamageEachPlayer: resolve damage effect
DamageEachPlayer->>QuantityResolver: resolve target-scoped mana values
DamageEachPlayer->>CastFromZone: offer optional free cast of exiled card
Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
🧹 Nitpick comments (4)
crates/engine/tests/integration/lady_loki_agent_of_chaos.rs (1)
159-167: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick winAssert the hit's zone instead of discarding the binding.
let _ = hit;discards the id. The test then has no reach guard proving the dig actually exiled a nonland card with mana value 1. If the dig failed and the amount fell back to another path that also produced 2, the test would pass for the wrong reason.💚 Replace the discard with a reach guard
- let _ = hit; - let mut runner = scenario.build(); let outcome = runner.cast(spell).x(3).resolve(); + + // Reach guard: the dig exiled the nonland hit, so MV(hit) = 1 was read. + assert_eq!( + outcome.zone_of(hit), + engine::types::zones::Zone::Exile, + "the nonland hit must be exiled by the dig" + );As per path instructions: "require a paired positive reach-guard proving the input actually reached the code under test".
🤖 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/tests/integration/lady_loki_agent_of_chaos.rs` around lines 159 - 167, Replace the discarded hit binding in the test setup with a positive reach guard that asserts the card identified by hit is in the exile zone after the dig. Keep the existing result assertion, ensuring the test proves the nonland mana-value-1 card reached the code path under test.Source: Path instructions
crates/engine/src/game/effects/deal_damage.rs (2)
3081-3088: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick winTighten the chain assertion to an exact match.
The assertion uses
any(...). A regression that re-stashes the paused first opponent (P1) alongside P2 would still pass. Assert the full summary instead, so the test pins both the amount and the exact remaining-player set.💚 Exact-match assertion
- let summary = collect_chain_summary(&cont.chain); - assert!( - summary.iter().any( - |(_, target, amount)| *target == TargetRef::Player(PlayerId(2)) && *amount == 3 - ), - "remaining opponent must be stashed with |MV(spell) − MV(hit)| = 3 (a \ - target-dropped pre-resolve gives 4): {summary:?}" - ); + let summary = collect_chain_summary(&cont.chain); + assert_eq!( + summary, + vec![(source, TargetRef::Player(PlayerId(2)), 3)], + "only the remaining opponent must be stashed, with \ + |MV(spell) − MV(hit)| = 3 (a target-dropped pre-resolve gives 4)" + );As per path instructions: "Test adequacy is the highest-frequency contributor finding — scrutinize 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/engine/src/game/effects/deal_damage.rs` around lines 3081 - 3088, Update the assertion around collect_chain_summary to require an exact summary rather than merely finding a matching entry: verify the remaining stashed player set contains only PlayerId(2) and that its damage amount is 3, so an extra PlayerId(1) entry fails the test.Source: Path instructions
1028-1037: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick winAdd a negative case that proves the guard blocks propagation.
cast_tail_with_parent_targetscopies the parent's object targets into every stashed tail whenshould_propagate_parent_targetsreturns true. The new regression test at Line 2964 only covers the true branch. No test covers the false branch on a pause path. If the predicate ever diverges fromresolve_ability_chain, a plain multi-targetDealDamagetail would silently gain the parent's object referents and damage the wrong recipients after a replacement pause.Add a paired case: a parent
DealDamagewith two object targets and a sub whosetarget_choice_timingisResolution, then assert the stashed tail keepstargets.is_empty().As per path instructions: "For every negative assertion … require a paired positive reach-guard proving the input actually reached the code under test".
🤖 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/effects/deal_damage.rs` around lines 1028 - 1037, Add a paired regression test near the existing propagation test for cast_tail_with_parent_targets: use a parent DealDamage with two object targets and a sub whose target_choice_timing is Resolution, then assert the stashed tail’s targets remain empty. Include a positive reach-guard confirming the replacement pause reaches the stashed-tail path before the negative assertion, and leave the true-branch coverage intact.Source: Path instructions
crates/engine/src/parser/oracle_nom/quantity.rs (1)
4628-4639: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winCompose the
nonprefix as its own axis instead of enumerating two literals.
parse_card_type_qualifierenumeratesnonlandandnoncreatureas separate literal arms, while delegating bare types toparse_core_type. Thenonprefix is an independent axis. Composing it overparse_core_typecovers the whole class (nonartifact card's mana value,nonenchantment card's mana value, …) with the same node count.♻️ Compose the prefix axis
fn parse_card_type_qualifier(input: &str) -> OracleResult<'_, ()> { terminated( - alt(( - value((), tag("nonland")), - value((), tag("noncreature")), - value((), tag("permanent")), - value((), parse_core_type), - )), + value( + (), + ( + opt(tag("non")), + alt((value((), tag("permanent")), value((), parse_core_type))), + ), + ), tag(" "), ) .parse(input) }As per coding guidelines: "Compose
alt()per axis; never enumerate the cartesian product as separatetag("full string")arms."🤖 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/parser/oracle_nom/quantity.rs` around lines 4628 - 4639, Update parse_card_type_qualifier to parse the optional “non” prefix separately and compose it with parse_core_type, while retaining the standalone “permanent” qualifier and trailing-space requirement. Remove the separate nonland and noncreature literal arms so all non-prefixed and non-prefixed core card types are handled through the shared parser.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.
Nitpick comments:
In `@crates/engine/src/game/effects/deal_damage.rs`:
- Around line 3081-3088: Update the assertion around collect_chain_summary to
require an exact summary rather than merely finding a matching entry: verify the
remaining stashed player set contains only PlayerId(2) and that its damage
amount is 3, so an extra PlayerId(1) entry fails the test.
- Around line 1028-1037: Add a paired regression test near the existing
propagation test for cast_tail_with_parent_targets: use a parent DealDamage with
two object targets and a sub whose target_choice_timing is Resolution, then
assert the stashed tail’s targets remain empty. Include a positive reach-guard
confirming the replacement pause reaches the stashed-tail path before the
negative assertion, and leave the true-branch coverage intact.
In `@crates/engine/src/parser/oracle_nom/quantity.rs`:
- Around line 4628-4639: Update parse_card_type_qualifier to parse the optional
“non” prefix separately and compose it with parse_core_type, while retaining the
standalone “permanent” qualifier and trailing-space requirement. Remove the
separate nonland and noncreature literal arms so all non-prefixed and
non-prefixed core card types are handled through the shared parser.
In `@crates/engine/tests/integration/lady_loki_agent_of_chaos.rs`:
- Around line 159-167: Replace the discarded hit binding in the test setup with
a positive reach guard that asserts the card identified by hit is in the exile
zone after the dig. Keep the existing result assertion, ensuring the test proves
the nonland mana-value-1 card reached the code path under test.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro Plus
Run ID: d316f225-37bf-4bb9-a19b-238ca6be8e18
📒 Files selected for processing (6)
crates/engine/src/game/effects/deal_damage.rscrates/engine/src/game/effects/mod.rscrates/engine/src/game/quantity.rscrates/engine/src/parser/oracle_nom/quantity.rscrates/engine/tests/integration/lady_loki_agent_of_chaos.rscrates/engine/tests/integration/main.rs
|
Generated for head Parse changes introduced by this PR · 1 card(s), 2 signature(s) (baseline: main
|
|
Maintainer hold for current head |
matthewevans
left a comment
There was a problem hiding this comment.
Request changes — current head 6d705c61c44a93d86223a23d68f5e64e8631eb55 has an unaccounted parser blast radius.
The current parse-diff reports O-Kagachi Made Manifest changing from unsupported where_x_binding to supported Pump (+target's mana value/+0) alongside the intended Lady Loki change: #6945 (comment). The new generic that [type] card qualifier is the shared parser seam (crates/engine/src/parser/oracle_nom/quantity.rs:4617-4668), and O-Kagachi uses it after a player chooses a nonland card in a graveyard, returns that chosen card, then gets +X/+0 for its mana value (data/mtgish-cards.json:28199).
Either add a production-pipeline O-Kagachi scenario proving the real choose → return → mana-value pump chain (including a discriminating nonzero mana value), or narrow/justify this additional parse change so coverage stays honest. Lady Loki-only tests do not establish the selected-authority/target threading for O-Kagachi's distinct choice path.
I also checked current CodeRabbit feedback: its only review is against older head 269497e4... and there are no unresolved current-head threads, so no separate current CodeRabbit reach-guard request applies.
…ghten Lady Loki tests
Addresses the PR review parser blast-radius: the opt(parse_card_type_qualifier)
"that card" arms widened the seam so a BARE "that card" also lowered to
ObjectScope::Target. That flipped O-Kagachi Made Manifest's "...where X is the
mana value of that card" from an honest where_x_binding gap into a dishonest
Pump (+target's mana value/+0) -- but O-Kagachi's "that card" is a card the
defending player CHOSE from a graveyard, not a threaded target, so no target is
ever wired for the pump to read.
Narrow both the possessive and prepositional arms to REQUIRE the type qualifier
(parse_card_type_qualifier), keeping Lady Loki's type-qualified "that nonland
card's mana value" (bound to the ExileFromTopUntil producer) while reverting
O-Kagachi to its baseline where_x_binding -- verified end-to-end via
parse_oracle_text. Also compose the non prefix as its own opt(tag("non"))
axis over parse_core_type so every "non<type>" qualifier is covered by one node
set instead of enumerated literals.
Tests:
- rename/extend the positive scope test to cover the composed non-axis
- add bare_of_that_card_mana_value_is_not_target_scope honesty guard (O-Kagachi
prepositional form), paired with the positive reach-guard
- Lady Loki X-spell test: replace `let _ = hit;` with an Exile reach guard
- exact-match the DamageEachPlayer pause chain summary
- add the false-branch pair proving a Resolution-timed sub does not inherit
parent targets on the pause path
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 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/parser/oracle_nom/quantity.rs`:
- Around line 4632-4636: Correct the documentation above the non qualifier
parser by removing the inaccurate CR 205.2b citation or replacing it with a
verified rule citation whose text explicitly defines the non qualifier behavior;
leave the parser implementation and canonical parse_core_type delegation
unchanged.
- Around line 4645-4647: Extend parse_core_type to recognize the vanguard card
type so parse_card_type_qualifier supports complete card-type coverage. Add
regression cases for both “that vanguard card’s mana value” and “mana value of
that vanguard card,” covering the possessive and prepositional forms while
preserving existing card-type parsing.
🪄 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: a5559522-fee0-4131-845e-35f82f1f7ef4
📒 Files selected for processing (5)
crates/engine/src/game/effects/deal_damage.rscrates/engine/src/game/quantity.rscrates/engine/src/parser/oracle_nom/quantity.rscrates/engine/tests/integration/lady_loki_agent_of_chaos.rscrates/engine/tests/integration/main.rs
🚧 Files skipped from review as they are similar to previous changes (4)
- crates/engine/tests/integration/main.rs
- crates/engine/tests/integration/lady_loki_agent_of_chaos.rs
- crates/engine/src/game/effects/deal_damage.rs
- crates/engine/src/game/quantity.rs
| opt(tag("non")), | ||
| alt((value((), tag("permanent")), value((), parse_core_type))), | ||
| ), |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Support the missing vanguard card type.
parse_card_type_qualifier claims complete card-type coverage but delegates to parse_core_type, which excludes vanguard. Therefore, that vanguard card's mana value and mana value of that vanguard card do not parse. Add vanguard to the shared parser and add possessive and prepositional regression cases. Vanguard is a card type under CR 205.2a and CR 300.1. (media.wizards.com)
As per path instructions, strict fidelity to the Comprehensive Rules is non-negotiable.
Also applies to: 10887-10896
🤖 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/parser/oracle_nom/quantity.rs` around lines 4645 - 4647,
Extend parse_core_type to recognize the vanguard card type so
parse_card_type_qualifier supports complete card-type coverage. Add regression
cases for both “that vanguard card’s mana value” and “mana value of that
vanguard card,” covering the possessive and prepositional forms while preserving
existing card-type parsing.
Source: Path instructions
|
@matthewevans thanks for the catch on the parse blast radius. Addressed in O-Kagachi (the blast radius): the Both the possessive and prepositional arms now require the type qualifier. Lady Loki's type-qualified "that nonland card's mana value" (bound to the So the regenerated parse-diff should now show only Lady Loki. No separate O-Kagachi choose→return→pump scenario is added, since that path (a defending-player choice from a graveyard) isn't implemented and this keeps coverage honest rather than claiming it. Also in this commit:
|
matthewevans
left a comment
There was a problem hiding this comment.
Current-head review for 5ec8afdd28365abe6fb7b716bf847349a799a948 — changes requested.
[MED] The parser coverage artifact is not bound to this review head. Evidence: the full <!-- coverage-parse-diff --> marker was updated at 2026-08-04T02:48:54Z, but its body says it was generated for c869f45cfab59ab48a3013749994e71cbefb616f, rather than 5ec8afdd28365abe6fb7b716bf847349a799a948; parser files changed in the intervening merge. Why it matters: the current card-level parse blast radius is therefore unverified. Suggested fix: regenerate the parse-diff artifact for the current head before coverage acceptance.
[MED] The new qualifier documentation has false rules provenance and an inaccurate completeness claim. Evidence: crates/engine/src/parser/oracle_nom/quantity.rs:4632-4637 attributes the non prefix to CR 205.2b and says the delegated parser covers the full core set. CR 205.2b instead concerns multi-card-type applicability; the canonical parse_core_type intentionally omits Vanguard because CoreType does not model the out-of-scope Vanguard avatars (crates/engine/src/parser/oracle_nom/primitives.rs:345-354). Why it matters: this repository requires every CR annotation to describe the code it annotates. Suggested fix: remove the CR 205.2b attribution and describe the qualifier as covering the supported CoreType vocabulary; do not add new Vanguard support in this PR.
… qualifier doc The parse_card_type_qualifier doc attributed the `non` prefix to CR 205.2b and claimed complete coverage of the card-type set. Both are inaccurate: - CR 205.2b concerns multi-card-type applicability, not a `non` qualifier. Drop the citation; the `non` prefix is a plain grammatical axis with no dedicated CR. - parse_core_type intentionally omits `vanguard` (CoreType models no Vanguard variant), so the qualifier does NOT cover the full CR 300.1 card-type set. Describe coverage as exactly the supported CoreType vocabulary and note vanguard is out of scope and not added by this PR. Per-repo rule: every CR annotation must describe the code it annotates. No behavior change; documentation only. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Maintainer fixup for current-main line movement. Co-authored-by: Jacob Woodson <38709105+JacobWoodson@users.noreply.github.com>
|
Maintainer fixup pushed at |
matthewevans
left a comment
There was a problem hiding this comment.
Approved for current head 70332cfbe082743e64ce7fdce9be763b4c254692.
The current parse-diff is bound to this head and confines parser impact to Lady Loki (one card, two signatures). Current CI is green. The remaining open CodeRabbit Vanguard thread is refuted by the current code: parse_card_type_qualifier explicitly consumes only the supported CoreType vocabulary, and CoreType intentionally has no Vanguard variant; expanding that engine model is outside this PR's Lady Loki seam.
Bring the PR up to date with main @ cdb99ba (Lady Loki, Agent of Chaos phase-rs#6945) and resolve the CR 603.5 prompt-census pin conflict. phase-rs#6945 re-baselined the three pinned `WaitingFor::OptionalEffectChoice` producers 5996/6073/9048 -> 5999/6076/9051 (its own +3 above them). This PR then inserts +13 above the same producers (paid graveyard-cast support at effects/mod.rs:2957), so the merged coordinates are 5999/6076/9051 -> 6012/6089/9064. The producers are byte-identical, add zero new census needles, scoped_library_search.rs:452 and engine.rs:11427 are unmoved, and the total/partition stay 37 and 5/7/25. Pure coordinate drift — pin resolved to the merged coordinates with an updated drift-log entry; census assertion verified matching on the merged tree (the Linux path-separator form the pin uses is what CI scans). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… route
A creature's activated ability was unreachable whenever an attached Aura or
Equipment was activatable too. Reported for Slumbering Keepguard (`{2}{W}`);
reproduces with Cooped Up, whose `{2}{W}: Exile enchanted creature` is
legitimately activatable from the battlefield, so no engine defect is needed.
During Priority `HumanResponseModel::ExactCandidates` publishes an attachment
fan for every activatable attachment, so `viewerInteraction.attachmentFans`
carries an entry for the host. The `attachmentsActionable` branch read that as
"the host is not a legal choice" — an unchecked premise — and sat above the
activation branch, so every click on the host opened the chooser instead. The
chooser excludes the host by design (`id !== host.id`), so the host's ability
had no path at all. The sibling branch that reads the affordance sets is placed
last for exactly this reason and documents it; this one was not.
Moved below the host's own target / activation / undo intent, which also makes
the click ladder match the glow-ring priority ladder it advertises. Introduced
by phase-rs#6945 (a card PR), which is why the click order was never reviewed as such.
Handing the click back to the host strands the attachments unless they keep a
route of their own, and they had none in this state: `attachmentsActionable` is
itself one of the disjuncts that expands the attachment stack, so no `+N`
control renders, while the `⧉` control required exactly one attachment. Each
Aura was then reachable only through a ~22px peek behind the host face. `⧉` now
renders whenever attachments are present and the stack is expanded — the same
predicate `+N` is derived from, so the two are complementary and exactly one
route exists in every state that needs one.
The `⧉` label counts what the fan will actually show
(`interactionAttachmentFan?.children.length`), not `obj.attachments.length`:
while an interaction is live the fan builds its cards from the projection, so
counting the raw snapshot would promise more cards than appear, and would
rediscover from the snapshot what the note above that line forbids.
`permanent.viewAttachmentsFor` now interpolates a real count, which exposed a
missing CLDR plural category: Polish selects `few`/`many` at 2/3/5 and neither
key existed, so `pl` fell back to English. Added `_few`/`_many` in all seven
catalogs (never selected outside `pl`, required by the `en`-oracle parity gate).
`permanent.hiddenAttachmentsAria` carried the identical defect and is fixed in
the same pass rather than left as the odd one out of a two-member class.
The control's SIZE is deliberately unchanged. It is now the pointer route where
the host's own click used to open the fan, and at `clamp(20px, …, 28px)` it is
under the 44px touch floor the branch above cites — but 44px is unreachable at
this seam: battlefield cards sit in an 8px gap and `--card-base` floors at
3.5rem, so growing outward puts ~26px over the NEIGHBOUR's face at `z-40` and
steals its clicks, while growing inward swallows most of a 56px card. Either
would re-create the click theft this commit undoes. Recorded at the gate as a
known limitation needing a layout-level answer, alongside the same gap on `+N`.
Also resets `viewerInteraction` in the test suite's shared setup — without it
the rows that publish a fan leak it into every later row, and the suite only
passed because they were declared last.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… route
A creature's activated ability was unreachable whenever an attached Aura or
Equipment was activatable too. Reported for Slumbering Keepguard (`{2}{W}`);
reproduces with Cooped Up, whose `{2}{W}: Exile enchanted creature` is
legitimately activatable from the battlefield, so no engine defect is needed.
During Priority `HumanResponseModel::ExactCandidates` publishes an attachment
fan for every activatable attachment, so `viewerInteraction.attachmentFans`
carries an entry for the host. The `attachmentsActionable` branch read that as
"the host is not a legal choice" — an unchecked premise — and sat above the
activation branch, so every click on the host opened the chooser instead. The
chooser excludes the host by design (`id !== host.id`), so the host's ability
had no path at all. The sibling branch that reads the affordance sets is placed
last for exactly this reason and documents it; this one was not.
Moved below the host's own target / activation / undo intent, which also makes
the click ladder match the glow-ring priority ladder it advertises. Introduced
by phase-rs#6945 (a card PR), which is why the click order was never reviewed as such.
Handing the click back to the host strands the attachments unless they keep a
route of their own, and they had none in this state: `attachmentsActionable` is
itself one of the disjuncts that expands the attachment stack, so no `+N`
control renders, while the `⧉` control required exactly one attachment. Each
Aura was then reachable only through a ~22px peek behind the host face. `⧉` now
renders whenever attachments are present and the stack is expanded — the same
predicate `+N` is derived from, so the two are complementary and exactly one
route exists in every state that needs one.
The `⧉` label counts what the fan will actually show
(`interactionAttachmentFan?.children.length`), not `obj.attachments.length`:
while an interaction is live the fan builds its cards from the projection, so
counting the raw snapshot would promise more cards than appear, and would
rediscover from the snapshot what the note above that line forbids.
`permanent.viewAttachmentsFor` now interpolates a real count, which exposed a
missing CLDR plural category: Polish selects `few`/`many` at 2/3/5 and neither
key existed, so `pl` fell back to English. Added `_few`/`_many` in all seven
catalogs (never selected outside `pl`, required by the `en`-oracle parity gate).
`permanent.hiddenAttachmentsAria` carried the identical defect and is fixed in
the same pass rather than left as the odd one out of a two-member class.
The control's SIZE is deliberately unchanged. It is now the pointer route where
the host's own click used to open the fan, and at `clamp(20px, …, 28px)` it is
under the 44px touch floor the branch above cites — but 44px is unreachable at
this seam: battlefield cards sit in an 8px gap and `--card-base` floors at
3.5rem, so growing outward puts ~26px over the NEIGHBOUR's face at `z-40` and
steals its clicks, while growing inward swallows most of a 56px card. Either
would re-create the click theft this commit undoes. Recorded at the gate as a
known limitation needing a layout-level answer, alongside the same gap on `+N`.
Also resets `viewerInteraction` in the test suite's shared setup — without it
the rows that publish a fan leak it into every later row, and the suite only
passed because they were declared last.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… route
A creature's activated ability was unreachable whenever an attached Aura or
Equipment was activatable too. Reported for Slumbering Keepguard (`{2}{W}`);
reproduces with Cooped Up, whose `{2}{W}: Exile enchanted creature` is
legitimately activatable from the battlefield, so no engine defect is needed.
During Priority `HumanResponseModel::ExactCandidates` publishes an attachment
fan for every activatable attachment, so `viewerInteraction.attachmentFans`
carries an entry for the host. The `attachmentsActionable` branch read that as
"the host is not a legal choice" — an unchecked premise — and sat above the
activation branch, so every click on the host opened the chooser instead. The
chooser excludes the host by design (`id !== host.id`), so the host's ability
had no path at all. The sibling branch that reads the affordance sets is placed
last for exactly this reason and documents it; this one was not.
Moved below the host's own target / activation / undo intent, which also makes
the click ladder match the glow-ring priority ladder it advertises. Introduced
by phase-rs#6945 (a card PR), which is why the click order was never reviewed as such.
Handing the click back to the host strands the attachments unless they keep a
route of their own, and they had none in this state: `attachmentsActionable` is
itself one of the disjuncts that expands the attachment stack, so no `+N`
control renders, while the `⧉` control required exactly one attachment. Each
Aura was then reachable only through a ~22px peek behind the host face. `⧉` now
renders whenever attachments are present and the stack is expanded — the same
predicate `+N` is derived from, so the two are complementary and exactly one
route exists in every state that needs one.
The `⧉` label counts what the fan will actually show
(`interactionAttachmentFan?.children.length`), not `obj.attachments.length`:
while an interaction is live the fan builds its cards from the projection, so
counting the raw snapshot would promise more cards than appear, and would
rediscover from the snapshot what the note above that line forbids.
`permanent.viewAttachmentsFor` now interpolates a real count, which exposed a
missing CLDR plural category: Polish selects `few`/`many` at 2/3/5 and neither
key existed, so `pl` fell back to English. Added `_few`/`_many` in all seven
catalogs (never selected outside `pl`, required by the `en`-oracle parity gate).
`permanent.hiddenAttachmentsAria` carried the identical defect and is fixed in
the same pass rather than left as the odd one out of a two-member class.
The control's SIZE is deliberately unchanged. It is now the pointer route where
the host's own click used to open the fan, and at `clamp(20px, …, 28px)` it is
under the 44px touch floor the branch above cites — but 44px is unreachable at
this seam: battlefield cards sit in an 8px gap and `--card-base` floors at
3.5rem, so growing outward puts ~26px over the NEIGHBOUR's face at `z-40` and
steals its clicks, while growing inward swallows most of a 56px card. Either
would re-create the click theft this commit undoes. Recorded at the gate as a
known limitation needing a layout-level answer, alongside the same gap on `+N`.
Also resets `viewerInteraction` in the test suite's shared setup — without it
the rows that publish a fan leak it into every later row, and the suite only
passed because they were declared last.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… route (phase-rs#7296) * fix(client): give a host's own click back to the host, and keep a fan route A creature's activated ability was unreachable whenever an attached Aura or Equipment was activatable too. Reported for Slumbering Keepguard (`{2}{W}`); reproduces with Cooped Up, whose `{2}{W}: Exile enchanted creature` is legitimately activatable from the battlefield, so no engine defect is needed. During Priority `HumanResponseModel::ExactCandidates` publishes an attachment fan for every activatable attachment, so `viewerInteraction.attachmentFans` carries an entry for the host. The `attachmentsActionable` branch read that as "the host is not a legal choice" — an unchecked premise — and sat above the activation branch, so every click on the host opened the chooser instead. The chooser excludes the host by design (`id !== host.id`), so the host's ability had no path at all. The sibling branch that reads the affordance sets is placed last for exactly this reason and documents it; this one was not. Moved below the host's own target / activation / undo intent, which also makes the click ladder match the glow-ring priority ladder it advertises. Introduced by phase-rs#6945 (a card PR), which is why the click order was never reviewed as such. Handing the click back to the host strands the attachments unless they keep a route of their own, and they had none in this state: `attachmentsActionable` is itself one of the disjuncts that expands the attachment stack, so no `+N` control renders, while the `⧉` control required exactly one attachment. Each Aura was then reachable only through a ~22px peek behind the host face. `⧉` now renders whenever attachments are present and the stack is expanded — the same predicate `+N` is derived from, so the two are complementary and exactly one route exists in every state that needs one. The `⧉` label counts what the fan will actually show (`interactionAttachmentFan?.children.length`), not `obj.attachments.length`: while an interaction is live the fan builds its cards from the projection, so counting the raw snapshot would promise more cards than appear, and would rediscover from the snapshot what the note above that line forbids. `permanent.viewAttachmentsFor` now interpolates a real count, which exposed a missing CLDR plural category: Polish selects `few`/`many` at 2/3/5 and neither key existed, so `pl` fell back to English. Added `_few`/`_many` in all seven catalogs (never selected outside `pl`, required by the `en`-oracle parity gate). `permanent.hiddenAttachmentsAria` carried the identical defect and is fixed in the same pass rather than left as the odd one out of a two-member class. The control's SIZE is deliberately unchanged. It is now the pointer route where the host's own click used to open the fan, and at `clamp(20px, …, 28px)` it is under the 44px touch floor the branch above cites — but 44px is unreachable at this seam: battlefield cards sit in an 8px gap and `--card-base` floors at 3.5rem, so growing outward puts ~26px over the NEIGHBOUR's face at `z-40` and steals its clicks, while growing inward swallows most of a 56px card. Either would re-create the click theft this commit undoes. Recorded at the gate as a known limitation needing a layout-level answer, alongside the same gap on `+N`. Also resets `viewerInteraction` in the test suite's shared setup — without it the rows that publish a fan leak it into every later row, and the suite only passed because they were declared last. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(engine): publish attachment-fan membership from the interaction projection The fan's membership was decided in `client/src`: it walked `GameObject .attachments`, merged `viewerInteraction.attachmentFans` from every descendant and produced the list it rendered. That is a second interaction authority, and it read an offer list as an inventory list — `attachment_fans_for_object_choices` publishes per DIRECT host and only for a child with exactly one legal choice, so one attachment's Equip erased its siblings from the only surface that shows attached cards at all. `ViewerInteraction` now carries `attachment_views`: for every object the viewer can see, the whole attachment subtree in depth-first order, each card holding the engine's opaque submission when one was published for it — under any host in that subtree, nested fans included — and `None` otherwise. Both directions of every relationship must agree, the same guard the fans already applied, so stale or authority-only back-links cannot reach a consumer. It is a second field rather than a widening of `attachment_fans` because the two answer different questions. A fan is what this viewer may submit right now, so it is authorization-scoped and empty for everyone else — the existing contract pins that. Membership is a board fact (CR 301.5 / CR 303.4: an attached permanent is its own object on the battlefield), so it follows visibility instead and is published on every projection, including an opponent's turn, an open prompt and a terminal game. Folding one into the other would either delete that contract assertion or make attachments vanish from the display whenever the viewer cannot act. The client renders and counts the projection and nothing else: `AttachmentFan` maps `cards` and gates each pick on that card's submission (else the shared `deriveActivationAffordances` authority the battlefield ring uses), and the badge label counts the same list, so label and fan can no longer disagree. A published pick is offered only while `canSubmit`, which the click path already required. Engine contract rows: the nested route submits through production dispatch from the outer host's view, membership survives an unauthorized viewer while the fan does not, a stale link in either direction publishes nothing, and the projection crosses the adapter round trip with `submission: null` intact and a missing field still loading. Counter-measured: direct children only -> `left: [2, 4] right: [2, 3, 4]`; dropping the back-link check -> the stale-link row; published-only membership in the client -> 11 red rows; dropping `canSubmit` -> 1. Wire protocol 21 -> 22: the field parses on a v21 peer as an empty map, so the loss is silent and the handshake is the only place the pairing can be refused. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(engine): bound every viewer projection through one exit Membership is derived before the authorization, session and slot gates, so the terminal, unauthorized, unbound-authority, invalid-serial and oversized-slot returns each carried an attachment map that only the derived path ever charged against the outbound budget. An early return could therefore serialize a visible attachment tree of any size. `derive_viewer_interaction` now leaves through a single `finalize_viewer_interaction`, so one aggregate budget governs every outward projection and failure drops the unbounded lists with PayloadTooLarge instead of shipping a truncated payload. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(engine): let attachment-view overflow reach the budget gate The membership derivation absorbed its own overflow: a host whose subtree passed MAX_INTERACTION_LIST_LEN was skipped, and an over-limit host map was replaced by an empty one. Both left the finalizer a small, plausible projection with nothing to object to, so the viewer read a bounded empty map as an authoritative "nothing is attached". `attachment_views_for_viewer` now returns Result and the reason code travels to `finalize_viewer_interaction`, which installs the membership and owns the single fail-closed answer for both derivation and aggregate overflow. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(engine): bound the attachment view while it is derived `attachment_views_for_viewer` built each host's whole descendant tree and only then asked whether it fit, and walked those descendants again for every ancestor above them. The aggregate bound in `bound_outbound_view` did reject the finished payload, so the ANSWER was already fail-closed — but it was reached only after the construction work was done. A chain 10 000 links long is the worst case: it is the longest chain in which no single view exceeds the per-view cap, so the per-host check never fires and the derivation runs to completion. Measured on the new fixture, that costs 23.2 s and peaks at 7.4 GB resident for a payload of 49 995 000 card entries. CR 732.2's runaway-cascade guard permits one dispatch to grow the board by 16 000 objects, so this is a reachable board rather than an invalid fixture, and the engine ships to WASM, where 7.4 GB is linear-memory exhaustion rather than a slow frame. The walk is now iterative — its depth is the attachment chain's depth, which is a game-controlled quantity and does not belong on the call stack — and it carries the running aggregate, stopping one card past the budget. The same fixture now costs 0.16 s. The running total is charged by addition rather than against a remaining allowance, so the bound cannot underflow if the accounting slips. Every card charged here is charged again by `bound_outbound_view` alongside the map, the fans and the opportunities, so the derivation's total is a lower bound on the finalizer's: it can never refuse a payload the finalizer would have accepted, and the finalizer stays the single authority on what the viewer is told. Regressions: `a_cap_depth_attachment_chain_is_refused_before_it_is_built` (the 10 000-link worst case, read through the early return, which reaches the same derivation without the 109 s of action enumeration the derived path owes over that board) and the existing chain row, deepened from 200 to 1 000 links so it exercises the derived path against a payload of 499 500 cards. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(engine): prove the membership derivation refuses before it builds The public rows in `interaction_contract` cannot carry this claim. The aggregate bound in `bound_outbound_view` already refused this payload, so the ANSWER is `Unsupported { PayloadTooLarge }` with or without the incremental charge; what the change moves is when the refusal happens. `the_membership_derivation_refuses_a_worst_case_chain_before_building_it` asserts on `attachment_views_for_viewer` itself. A chain of exactly `MAX_INTERACTION_LIST_LEN` links is the only depth that discriminates: it is the longest one in which no host's own subtree exceeds the per-view cap, so a derivation that measures after materializing runs to completion. One link longer and the outermost view trips that cap by itself. Two reach guards run first — the fixture is exactly `MAX_INTERACTION_LIST_LEN + 1` objects and no host carries more than one attachment — so an over-long or fanned-out fixture cannot fail the row for the wrong reason. Counter-measured: with the running charge dropped and the post-walk `cards.len()` check restored, it fails on `Ok`, holding 9 999 views and 49 995 000 cards. The assertion compares by discriminant and reports those two SIZES rather than the value, because printing the rejected `Ok` produced 3.2 GB of output for one failed assertion. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(PR-7296): cover projected attachment badge count Co-authored-by: cuinhellcat <217210902+cuinhellcat@users.noreply.github.com> * test(PR-7296): correct singular attachment label assertion Co-authored-by: cuinhellcat <217210902+cuinhellcat@users.noreply.github.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: matthewevans <matthewevans@users.noreply.github.com>
Summary
Adds engine support for Lady Loki, Agent of Chaos.
Files changed
CR references
Track
Developer
LLM
Model: claude-opus-4-8
Thinking: high
Tier: Frontier
Verification
cargo fmt --all— clean./scripts/check-parser-combinators.sh (Gate A)— clean (after env fix: hardcodedpython3resolved to the broken Windows App Store stub causing a false 'cross-product detector RED' failure; reran with msys64 python3 on PATH -> Gate G PASS + Gate A PASS)cargo clippy-strict— incomplete - still compiling in background (cold build; foreground attempt timed out at 10m, background run still 'Checking phase-engine', no result yet)cargo test -p phase-engine— not run (blocked on clippy-strict completing in the && chain)./scripts/gen-card-data.sh— not run (blocked on prior steps)cargo coverage— not runcargo semantic-audit— not runRe-verified at chunk-1 checkpoint with freshly regenerated card-data: all listed cards supported:true gap:0, semantic-audit clean. The run-time 'partial' was a stale-card-data artifact, not a code defect.
Scope Expansion
None. (Scope narrowed vs plan: the R6 zones/LKI change for EventSource-across-exile proved unnecessary — move_to_zone reuses the object id on Stack->Exile, so the exiled spell's off-stack MV is read directly.)
Validation Failures
See review/cross-check notes.
CI Failures
tilt get uiresource clippyexit 1), so the direct-cargo fallback path was correctly selected. Also,python3/pythonon PATH resolve to the Windows App Store stub (Permission denied, exit 126); a working interpreter exists at C:/msys64/mingw64/bin/python3.exe (3.9.7). Gate A only passes when that msys64 python3 is prepended to PATH.Summary by CodeRabbit
Bug Fixes
Tests