Skip to content

Fix Trade Caravan - #5

Open
keloide wants to merge 14 commits into
mainfrom
claude/phase-developer-track-owmkmw
Open

Fix Trade Caravan#5
keloide wants to merge 14 commits into
mainfrom
claude/phase-developer-track-owmkmw

Conversation

@keloide

@keloide keloide commented Jul 21, 2026

Copy link
Copy Markdown
Owner

Summary

Trade Caravan's activated ability Remove two currency counters from ~: Untap target basic land. Activate only during an opponent's upkeep. dropped its timing restriction to Effect::Unimplemented, leaving the ability activatable at any time. parse_activation_during_role_gate handled during an opponent's turn and during your upkeep but had no arm for during an opponent's upkeep. This adds that arm by composing existing building blocks rather than proliferating a new DuringOpponents* restriction sibling.

Implementation method (required)

Method: /engine-implementer (gates applied inline: add-engine-variant existence/parameterization/boundary gate + mandatory final review-impl)

CR references

  • CR 503.1 — the upkeep step (new ParsedCondition::IsDuringUpkeep predicate + its evaluator arm).
  • CR 102.1 — the active player is the player whose turn it is (opponent scope via Not(IsYourTurn)).
  • CR 602.5b — an activated ability's use restriction persists across control changes.

All three grep-verified against docs/MagicCompRules.txt.

Design

Rather than add a DuringOpponentsUpkeep sibling to ActivationRestriction (which already expresses opponent-turn scope via RequiresCondition { Not(IsYourTurn) }, not a dedicated DuringOpponentsTurn variant), the fix composes:

  • ParsedCondition::IsDuringUpkeep — a scope-free upkeep-step predicate, orthogonal to every existing ParsedCondition variant (there was no prior phase predicate).
  • during an opponent's upkeepRequiresCondition { And([Not(IsYourTurn), IsDuringUpkeep]) }, reusing the same opponent-turn composition idiom as the existing bare opponent-turn gate.

add-engine-variant gate result for ParsedCondition::IsDuringUpkeep: Stage 1 DOES_NOT_EXIST (no phase predicate in ParsedCondition), Stage 2 EXTEND_OK (orthogonal — not a sibling cluster), Stage 3 WITHIN_SECTION (CR 503, upkeep step).

Verification

  • Required checks ran clean, or the exact CI-owned alternative is stated below.

  • Gate A output below is for the current committed head.

  • Final review-impl below is clean for the current committed head.

  • Both anchors cite existing analogous code at the same seam.

  • cargo fmt --all — clean.

  • cargo clippy -p engine --all-targets -- -D warnings — clean, 0 warnings (full compile; confirms every exhaustive ParsedCondition match is satisfied).

  • cargo test -p engine --libok. 17440 passed; 0 failed; 6 ignored.

  • cargo test -p engine --test integrationok. 3676 passed; 0 failed; 2 ignored.

  • New tests: activated_ability_opponents_upkeep_restriction_composes_scope_and_step (parser building block, drives production parse(), asserts composed restriction + zero Effect::Unimplemented) and opponents_upkeep_activation_condition_gates_on_step_and_scope (runtime, drives real evaluate_condition across the full turn-scope × step matrix) — both pass.

  • cargo coverage / cargo semantic-auditdeferred to CI: both require a full MTGJSON card-data.json export (156 MB+ multi-request per-set fetch) that isn't feasible in this environment (only a test fixture is present locally). Direct evidence instead: the parsed AST for Trade Caravan now contains zero Effect::Unimplemented and the correct RequiresCondition { And([Not(IsYourTurn), IsDuringUpkeep]) } restriction. Since card_face_has_unimplemented_parts (game/coverage.rs) derives supported precisely from the absence of Unimplemented parts, Trade Caravan is now supported: true, gap_count: 0.

Gate A

Gate A PASS head=c5024c97678be3ffdcbac252e235cd8ed145fdab base=ee061e5c7485619644dc6089976b94a1d29a0202

Anchored on

  • crates/engine/src/parser/oracle.rs:6792value(opponents_turn_activation_restriction(), alt((tag("during an opponent's turn"), tag("during an opponents turn")))): the same value(computed_restriction, alt((tag, tag))) during-role arm the new opponent's-upkeep arm mirrors (combinator family, naming, placement).
  • crates/engine/src/parser/oracle.rs:6803value(ActivationRestriction::DuringYourUpkeep, alt((tag("during your upkeep"), tag("during their upkeep")))): the sibling upkeep-step during-role arm, immediately adjacent, establishing the apostrophe/possessive variant pattern reused verbatim.
  • (supporting) crates/engine/src/game/restrictions.rs:1520 ParsedCondition::IsYourTurn => state.active_player == player and restrictions.rs:1066 CastingRestriction::DuringOpponentsUpkeep => state.active_player != player && state.phase == Phase::Upkeep — the evaluator arms the new IsDuringUpkeep => state.phase == Phase::Upkeep arm mirrors.

Final review-impl

Final review-impl PASS head=c5024c97678be3ffdcbac252e235cd8ed145fdab

Claimed parse impact

  • Trade Caravan (removed from docs/parser-misparse-backlog.md root cause 28)

🤖 Generated with Claude Code


Generated by Claude Code

@github-actions

github-actions Bot commented Jul 21, 2026

Copy link
Copy Markdown

Parse changes introduced by this PR · 52 card(s), 40 signature(s) (baseline: main 945a92733f5d)

🟢 Added (2 signatures)

  • 2 cards · ➕ replacement/Moved · added: Moved (scope=self, to zone=battlefield)
    • Affected (first 3): Apocalypse Hydra, Bringer of Green Zenith's Twilight
  • 2 cards · ➕ ability/replacement_structure · added: replacement_structure
    • Affected (first 3): Henge Walker, Red and Black Legac

🔴 Removed (2 signatures)

  • 4 cards · ➖ replacement/Moved · removed: Moved (scope=self, to zone=battlefield)
    • Affected (first 3): Apocalypse Hydra, Bringer of Green Zenith's Twilight, Henge Walker (+1 more)
  • 1 card · ➖ ability/ChangeZone · removed: ChangeZone (target=self, to=battlefield)
    • Affected (first 3): Plague Reaver

🟡 Modified fields (21 signatures)

  • 5 cards · 🔄 ability/Sacrifice · changed field target: parent targetself
    • Affected (first 3): Contested Game Ball, Daredevil Dragster, Dawn of a New Age (+2 more)
  • 5 cards · 🔄 ability/Sacrifice · changed field target: triggering sourceself
    • Affected (first 3): Blood Spatter Analysis, Charitable Levy, Decree of Silence (+2 more)
  • 4 cards · 🔄 ability/RemoveCounter · changed field target: parent targetself
    • Affected (first 3): Heirloom Mirror, Ludevic's Test Subject, Replicating Ring (+1 more)
  • 2 cards · 🔄 ability/ChangeZone · changed field enters_under: ScopedPlayer
    • Affected (first 3): Gerrymandering, Thieves' Auction
  • 2 cards · 🔄 ability/DealDamage · changed field target: opponentopponent or opponent controls creature or opponent controls planeswalker
    • Affected (first 3): All Will Be One, Nicol Bolas, God-Pharaoh
  • 2 cards · 🔄 ability/Transform · changed field target: selfparent target
    • Affected (first 3): Grasping Shadows, Soulcipher Board
  • 1 card · 🔄 ability/Bounce · changed field conditional: instead if (3+ Green spent)instead if (mana spent to cast (SelfObject, Green mana) ≥ 3)
    • Affected (first 3): Once and Future
  • 1 card · 🔄 ability/ChangeZone · changed field enters_under: ParentTargetOwner
    • Affected (first 3): Jailbreak
  • 1 card · 🔄 ability/DamageAll · changed field conditional: instead if (7+ Red spent)instead if (mana spent to cast (SelfObject, Red mana) ≥ 7)
    • Affected (first 3): Sundering Stroke
  • 1 card · 🔄 ability/DealDamage · changed field conditional: 3+ Red spentmana spent to cast (SelfObject, Red mana) ≥ 3
    • Affected (first 3): Searing Barrage
  • 1 card · 🔄 ability/DealDamage · changed field conditional: instead if (3+ Red spent)instead if (mana spent to cast (SelfObject, Red mana) ≥ 3)
    • Affected (first 3): Slaying Fire
  • 1 card · 🔄 ability/DealDamage · changed field target: opponentopponent or battle or planeswalker
    • Affected (first 3): Jeska and Kamahl
  • 1 card · 🔄 ability/Draw · changed field conditional: P1P1 counters on self ≥ 1P1P1 counters on recipient ≥ 1
    • Affected (first 3): Dual-Sun Technique
  • 1 card · 🔄 ability/GainLife · changed field conditional: 3+ White spentmana spent to cast (SelfObject, White mana) ≥ 3
    • Affected (first 3): Rally for the Throne
  • 1 card · 🔄 ability/Mill · changed field conditional: 3+ Black spentmana spent to cast (SelfObject, Black mana) ≥ 3
    • Affected (first 3): Cauldron's Gift
  • 1 card · 🔄 replacement/Moved · changed field condition: OnlyIfQuantity { lhs: Ref { qty: ManaSpentToCast { scope: SelfObject, metric: OfColor { color: Black } } }, comparator:…
    • Affected (first 3): Locthwain Paladin
  • 1 card · 🔄 replacement/Moved · changed field condition: OnlyIfQuantity { lhs: Ref { qty: ManaSpentToCast { scope: SelfObject, metric: OfColor { color: Blue } } }, comparator: …
    • Affected (first 3): Vantress Paladin
  • 1 card · 🔄 replacement/Moved · changed field condition: OnlyIfQuantity { lhs: Ref { qty: ManaSpentToCast { scope: SelfObject, metric: OfColor { color: Green } } }, comparator:…
    • Affected (first 3): Garenbrig Paladin
  • 1 card · 🔄 replacement/Moved · changed field condition: OnlyIfQuantity { lhs: Ref { qty: ManaSpentToCast { scope: SelfObject, metric: OfColor { color: Red } } }, comparator: G…
    • Affected (first 3): Embereth Paladin
  • 1 card · 🔄 replacement/Moved · changed field condition: OnlyIfQuantity { lhs: Ref { qty: ManaSpentToCast { scope: SelfObject, metric: OfColor { color: White } } }, comparator:…
    • Affected (first 3): Ardenvale Paladin
  • 1 card · 🔄 replacement/Moved · changed field condition: OnlyIfQuantity { lhs: Ref { qty: ObjectCount { filter: Typed(TypedFilter { type_filters: [Land], controller: Some(You),…
    • Affected (first 3): Dust Animus
… 15 more signature(s) (15 card-changes) — showing first 15; see parse-diff.json

🟢 Added (3 signatures)

  • 1 card · ➕ ability/change_zone_enters_under_anaphor · added: change_zone_enters_under_anaphor
  • 1 card · ➕ ability/change_zone_enters_under_anaphor · added: change_zone_enters_under_anaphor (kind=activated)
  • 1 card · ➕ ability/change_zone_enters_under_anaphor · added: change_zone_enters_under_anaphor (targets=0+)

🔴 Removed (3 signatures)

  • 1 card · ➖ ability/SearchLibrary · removed: SearchLibrary (find=basic land, targets=0+)
  • 1 card · ➖ ability/TargetOnly · removed: TargetOnly (kind=activated, target=opponent)
  • 1 card · ➖ ability/activate · removed: activate

🟡 Modified fields (9 signatures)

  • 1 card · 🔄 replacement/Moved · changed field condition: OnlyIfQuantity { lhs: Ref { qty: ZoneCardCount { zone: Graveyard, card_types: [Instant, Sorcery], filter: None, scope: …
  • 1 card · 🔄 trigger/Phase · changed field condition: not (during you's turn)during each opponent's turn
  • 1 card · 🔄 ability/PreventDamage · changed field target: playerplayer or planeswalker or creature Sliver
  • 1 card · 🔄 ability/PutCounter · changed field target: triggering sourceself
  • 1 card · 🔄 ability/Scry · changed field conditional: 3+ Blue spentmana spent to cast (SelfObject, Blue mana) ≥ 3
  • 1 card · 🔄 ability/Token · changed field conditional: 3+ Black spentmana spent to cast (SelfObject, Black mana) ≥ 3
  • 1 card · 🔄 ability/Token · changed field conditional: 3+ Blue spentmana spent to cast (SelfObject, Blue mana) ≥ 3
  • 1 card · 🔄 ability/grant Vigilance · changed field conditional: 3+ White spent
  • 1 card · 🔄 ability/the · changed field conditional: 3+ Green spentmana spent to cast (SelfObject, Green mana) ≥ 3

8 card(s) had Oracle-text changes (errata/reprint) — excluded as non-parser.
New cards in head: 26.

@keloide
keloide force-pushed the claude/phase-developer-track-owmkmw branch from 315ee0e to d56780a Compare July 29, 2026 14:54
keloide and others added 7 commits August 1, 2026 17:59
* fix(engine): attach the leading intervening-if gate on self-ETB enters-with replacements

The Adamant cycle parsed its "enters with a +1/+1 counter" clause as a
CR 614.1c replacement but dropped the sentence-initial "If <condition>, "
gate, leaving ReplacementDefinition.condition = None. The engine therefore
gave every copy of these creatures the counter regardless of what mana was
actually spent — silently-wrong behaviour that nothing flagged at runtime.
Coverage reported it as Swallow:Condition_If on six cards.

Root cause: oracle_replacement.rs peeled a TRAILING " unless <cond>" gate
(extract_enters_with_unless_suffix) and a trailing " if <cond>" gate
(extract_enters_with_only_if_suffix), but nothing peeled a LEADING one, and
the payload scan skips everything before "enters with".

Fixed for the class, not the cards:

* extract_enters_with_leading_if_gate mirrors the existing trailing
  extractor one-for-one, including its fail-closed tri-state. Condition
  recognition delegates to parse_inner_condition (the single authority), so
  the ENTIRE static-condition grammar is now reachable from the
  sentence-initial position, where previously zero shapes were. An
  unrecognised gate returns Unparsed and the clause falls through to
  Effect::unimplemented rather than committing a replacement with the gate
  erased.
* CR 207.2c ability-word labels are peeled via parse_known_ability_word_name,
  which enumerates exactly the CR 207.2c list. CR 207.2d flavour words are
  deliberately NOT peeled: the loose 4-word heuristic would peel "Sensational
  Save" and regress Scarlet Spider, Ben Reilly to Unparsed.
* CastManaSpentMetric::OfColor parameterises the existing metric axis
  (Total | DistinctColors | FromSource) rather than adding a
  ReplacementCondition sibling, so the parsed condition flows through the
  untouched replacement_condition_from_static into OnlyIfQuantity. Zero new
  ReplacementCondition variants, zero new condition-evaluation arms.
* parse_color_mana_spent_threshold reuses parse_amount_threshold, so the
  comparator axis (at least N / N or more / N or fewer) is carried rather
  than hardcoded to GE.

Coverage: six cards flip to supported (Ardenvale, Embereth, Garenbrig,
Locthwain and Vantress Paladin, plus Necromantic Summons), zero cards
regress. Dust Animus keeps supported=true and gains its gate, so it is
correctly conditional for the first time. Henge Walker ("mana of the same
color" — a max-over-colors metric) and Red and Black Legacy now fail closed
and stay honestly red instead of silently granting their counters.

Measured against a pre-change baseline of all 35516 cards: 0 true->false,
6 false->true, net +6.

CR 614.1c, CR 614.12, CR 207.2c, CR 207.2d, CR 106.3, CR 601.2h, CR 400.7d.
CR 603.4 is deliberately not cited in code: its intervening-if rule is
scoped to triggered abilities, and this gates a replacement effect.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(engine): pin the Adamant static-grant rider gate at runtime

Silverflame Ritual's Adamant rider grants a continuous static effect
("creatures you control gain vigilance") rather than an ability-level
effect, so its gate lands on `StaticDefinition.condition` (CR 613 layer
6) instead of the ability's own `condition`. The CI parse-diff bot reads
the ability level only, so that layer move renders as
`conditional: 3+ White spent -> ∅`, which reads like a dropped gate --
the exact bug class this file guards.

It is not a drop: the condition is present and enforced. Verified at
runtime in both polarities, because nothing pinned this subclass. The
existing R8 rider guard (Slaying Fire) cannot catch it -- that payload
is ability-level -- so a future refactor really could drop the static's
condition with every other test here staying green.

Both new tests carry a positive reach-guard asserting the card's UNGATED
first line resolved (the +1/+1 counter), so the "no vigilance" negative
cannot pass vacuously on a spell that never resolved.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(PR-6780): complete Adamant gate coverage

* test(PR-6780): scope Locthwain Adamant fixture

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: matthewevans <matthewevans@users.noreply.github.com>
* fix: parse coordinated damage targets

* fix(PR-6833): retain coordinated target legs

---------

Co-authored-by: matthewevans <matthewevans@users.noreply.github.com>
Co-authored-by: matthewevans <matthewevans@users.noreply.github.com>
… the spell (phase-rs#6208) (phase-rs#6220)

* fix(parser): bind "twice its power" damage subject to the target, not the spell (phase-rs#6208)

Punishing Punch — "Target creature you control deals damage equal to twice its
power to target creature an opponent controls." — dealt 0 damage. "Its power"
names the first target (the creature dealing the damage). The singular form
lowers "its power" to Power{Anaphoric}, which the target-subject path rebinds to
Power{Target}. The multiplier form lowers it via the CDA path to
Multiply{2, Power{Source}}, and bind_damage_clause_source only rebinds Anaphoric
leaves, so Power{Source} survived, read the spell (power 0), and dealt nothing.

In the target-subject damage path (wrap_target_subject_damage), after binding
damage_source = Target, retarget any residual Source-scoped Power/Toughness leaf
in the amount to Target. In that path the subject is the target creature, never
the spell, so its self-referential characteristic must follow the subject.
Restricted to power/toughness so a legitimate ObjectManaValue{Source} ("this
spell's mana value") is left intact.

Duggan, Private Detective ("Duggan deals damage equal to twice its power") is
self-subject — a bare DealDamage never routed through the target-subject wrapper
— so its Power{Source} is untouched (its full-kit test is the regression guard).
Class of 4: Punishing Punch, Animist's Might, Polliwallop, Thing Swing. Runtime
already resolves Multiply{2, Power{Target}} + damage_source Target (Chandra's
Ignition, Duggan's runtime test), so no runtime change.

Tests: a parser test (amount is Multiply{2, Power{Target}}, damage_source Target)
with the singular and Duggan tests as regression guards, and a runtime test that
casts Punishing Punch and asserts 2 x the subject's power is dealt.

* fix(PR-6220): unify source quantity rebinding

---------

Co-authored-by: jaytbarimbao-collab <300663773+jaytbarimbao-collab@users.noreply.github.com>
Co-authored-by: matthewevans <matthewevans@users.noreply.github.com>
…Gemstone Mine phase-rs#6507) (phase-rs#6559)

* fix(parser): bind source-counter-gated rider pronouns to the source (Gemstone Mine phase-rs#6507)

The depletion-land sacrifice rider ("If there are no mining counters on
this land, sacrifice it.") parsed to Sacrifice{ParentTarget}. A mana
ability has no targets (CR 605.1a), so ParentTarget resolved to an empty
set and the sacrifice silently no-op'd — the land never left play.

Root cause is a parse-time anaphor mis-binding, not a runtime gap: the
chunk-subject threading in the effect-chain parser already binds a bare
"it" to SelfRef when the gating condition references the source object,
via condition_refs_source_object. That predicate recognized the
source-tapped / source-entered / source-attached conditions but not a
source-scoped counter threshold (QuantityCheck over CountersOn{Source}),
so the counter-gated riders fell through to ParentTarget (and, on typed
triggers, to TriggeringSource).

Extend condition_refs_source_object with a QuantityCheck arm that returns
true when either side reads counters on the source, via a new exhaustive
QuantityExpr walker (no wildcard — a future variant must be classified).
This single predicate extension drives both existing consumers: the
chunk-subject threading now supplies SelfRef, and the ParentTarget
rewrite guard now skips these chunks. Bindings become source-correct for
the Mercadian depletion lands (Peat Bog, Hickory Woodlot, Remote Farm,
Sandstone Needle, Saprazzan Skerry), Gemstone Mine, Tourach's Gate,
Daredevil Dragster, Last Light of Durin's Day, ED-E, and the whole
source-counter-conditioned rider class (~21 cards).

No runtime files change. CR 122.1 + CR 608.2k annotate the new arm.

Closes phase-rs#6507

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

* fix(parser): don't rebind a source-counter-gated pronoun over a prior chosen target (phase-rs#6559 review)

The phase-rs#6507 predicate that binds a source-counter-gated rider pronoun to
SelfRef was one degree too broad: it also fired on Revelation of Power
("Target creature gets +2/+2 until end of turn. If it has a counter on
it, it also gains flying and lifelink"), whose intervening-if mis-scopes
the bare "it" to CountersOn{Source}. Binding that grant to the source
dropped flying/lifelink onto the one-shot Instant — the card lost its
second sentence (engine_regress), and CR 608.2k does not reach it (the
source is named by neither a cost nor a trigger condition).

Narrow the binding: only rebind the pronoun to the source when NO earlier
clause in the chain chose a typed target. Compute one gate at the
chunk-subject site and reuse it at both consumers (the chunk-subject
binding and the replace_target_with_parent guard):

    let binds_source_counter_pronoun = condition
        .is_some_and(condition_refs_source_object)
        && !chain_has_prior_typed_referent(builder.clauses(), false);

chain_has_prior_typed_referent is true for Revelation of Power (its prior
"Target creature gets +2/+2" is a Pump over a typed target) and false for
every depletion-land / counter rider (whose prior clause is "Add mana" or
"put a counter on ~", never a chosen target), so all 21 intended heals
keep SelfRef while Revelation of Power's grant returns to ParentTarget.
Chosen deliberately over chain_prior_referent_is_chosen_target, whose
has_typed_target_widened early-out returns false for a pump-of-a-target.

Adds source_counter_gate_over_prior_target_keeps_parent_not_self_ref
(pins Revelation of Power's grant to ParentTarget) and extends the
predicate unit test to drive the Sum/Difference two-operand walker arms.

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

* test(PR-6559): cover Revelation of Power runtime binding

* fix(PR-6559): bind counter riders to prior targets

* fix(PR-6559): scope counter grants to their recipient

* fix(PR-6559): bind counter gate to its recipient

* test(PR-6559): cover counter gate operands

* test(PR-6559): correct counter fixture

* fix(PR-6559): rebind counter clause conditions

* docs(PR-6559): clarify counter gate binding

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: matthewevans <matthewevans@users.noreply.github.com>
…e-rs#6691) (phase-rs#6814)

* fix(parser): bind the CR 110.2a "under their control" clause (phase-rs#6691)

The Oracle parser recognised only "under your control" and the
owner-stating forms. "under their control" / "under that player's
control" fell through the empty-tag arm and were silently dropped, so
permanents entered under the wrong controller while the cards reported
supported: true, gap_count: 0 — a silent wrong-game-result class.

Collapse four independently-implemented copies of the
`under <possessor> control` grammar into one nom combinator module
(oracle_nom/enters_under.rs) and bind the anaphor from typed parse
products only:

  N1 — the moved object's own FilterProp::Owned { controller != You }
       (CR 400.1 + 400.3 + 404.1 + 108.3: a card in a graveyard is in
       its owner's graveyard) -> ControllerRef::ParentTargetOwner
  N2 — ctx.relative_player_scope, mapped through an exhaustive
       wildcard-free table that admits only ScopedPlayer, ChosenPlayer
       and TriggeringPlayer

Everything else fails closed to Effect::unimplemented rather than
silently defaulting, so a clause the parser cannot bind becomes an
honest coverage gap instead of a wrong controller. EntersUnderSpec
carries that third state through the AST to every lowering site.

Fixes Jailbreak (ParentTargetOwner) and Gerrymandering (ScopedPlayer)
and their templates; 933 "your"/owner-form cards keep byte-identical
behaviour through the single grammar.

Addresses phase-rs#6691; does not close it — see the PR body for the residue.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Nr9Cg6eBZfBod5Twb6Vyi

* test(engine): make the phase-rs#6691 evidence honest — fixture refresh + scope correction

Review found the integration test passed with the fix reverted. Two causes,
both fixed here:

1. The surgically-added `jailbreak` fixture entry was copied from a PRE-FIX
   `card-data.json`, so it carried no `enters_under` key. Since the field is
   `skip_serializing_if = "Option::is_none"`, it deserialized as the unfixed
   AST and the test measured old behaviour while appearing to pass. The entry
   is regenerated from post-fix card data (+0 keys, only `jailbreak` changed)
   and a fixture-integrity guard now fails loudly if it ever goes stale again.

2. The test claimed to be revert-failing. It is not, and that was MEASURED, not
   assumed: with `enters_under` stripped it still passes, because a graveyard →
   battlefield move already resolves the new object's controller to its OWNER,
   which for Jailbreak is exactly the player the clause names. Staging a
   divergent controller does not separate them either — the move reassigns it.
   The docstring now states this plainly and points at the discriminating test.

The discriminating test is the parser-level assertion
`jailbreak_enters_under_their_control_binds_to_the_owner`, verified by
temporarily binding `Default` instead of `ParentTargetOwner`: it FAILS on
revert and passes when restored.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Nr9Cg6eBZfBod5Twb6Vyi

* fix(parser): repair PR-6814 CI expectations

* fix(parser): fail closed search destination assembly

* fix(parser): preserve search arena identity on fail-closed destination

* test(parser): allow shuffle after fail-closed search gap

* fix(PR-6814): fail closed before attached return

* fix(parser): guard search-destination control regression

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: matthewevans <matthewevans@users.noreply.github.com>
…ative rulings (combo-fb phases 0-2, chain 1) (phase-rs#6838)

* feat(engine): retain loop-detection frames across forced pre-priority windows

A merely-riding observer suppressed the combo shortcut offer: the loop-detection
ring was cleared at two independent sites, so any forced pre-priority window
discarded every sampled frame and detection restarted from empty. Measured on a
real 4p dump: clears were {apply_action: 1152 at len 0, sampler: 16 at len 2} --
the sampler wiped a len-2 ring exactly once per 99-beat period.

- WaitingFor::is_forced_cascade_window() classifies the windows the engine forces
  before any player receives priority (CR 117.1) -- trigger ordering, trigger
  target selection, optional-effect choice, commander zone choice, the legend
  rule (CR 704.5j) and battle protector designation (CR 310.10, a state-based
  action). Fail-closed via '_ => false' over the remaining 121 variants.
- Both clear sites now honour the class. Fixing either alone yields zero
  retention, so the pair is the unit of change.
- ResourceVector gains the axes the multiplayer gain/drain proof needs;
  elimination_bounds now carries an explicit conditional-soundness contract
  naming the attribution precondition its max() form requires.

Assisted-by: ClaudeCode:claude-opus-4.8

* fix(engine): fence the loop ring on CR 510.2 damage; widen the forced-window class to turn-based actions

Two coupled changes that land together on purpose: the widening is what makes
the fence necessary, so splitting them would leave an intermediate commit in
which a retained frame pair can straddle unobserved combat damage.

Widening (CR 703.1 / CR 117.3a). is_forced_cascade_window() gains the seven
turn-based-action windows the engine forces before any player receives
priority: untap choice, bounded untap subset, declare attackers, exert,
enlist, declare blockers, cleanup discard. Measured on a real 4-player dump,
the ring was force-cleared exactly once per 99-beat turn period at
declare-attackers, capping it at 2 frames; the widened class reaches 13.
Retention across turns is necessary but NOT yet sufficient for the cross-turn
shortcut CR 732.2a contemplates -- loop_states_equal still compares
turn_number -- and the docs now say that rather than claiming otherwise.

Damage fence (CR 510.2 / CR 119.3). The class doc excluded AssignCombatDamage
as life-moving, but that window opens only when a damage-division choice is
required. An unblocked attacker deals damage with no window at all, so the
exclusion protected nothing once declare-attackers became exempt. The
prohibition is now keyed on the damage EVENT: one guard in the shared
apply_combat_damage, placed after the prevention riders so it catches Phase C
damage, lifelink and CR 615.5 riders alike. The predicate is != rather than
<, because CR 119.3 moves life in both directions. Per-batch (not hoisted)
capture is what keeps double-strike correct.

AssignCombatDamage/AssignBlockerDamage remain non-members, now documented with
the CR 510.2 rationale rather than left to inference. CombatTaxPayment is
documented as a life-mover (CR 508.1h + CR 107.4f: Phyrexian attack taxes),
correcting a false claim that the turn-based members change no life.

Tests: the exempt-window cast proof now fails loudly on class drift in both
directions, and every member row carries a reach-guard, so a fixture offering
no legal answer can no longer pass on an inert zero -- which caught three
pre-existing vacuous rows. Two fixtures that could not occur in a real game
(a player attacking themselves; a singleton trigger-ordering prompt the engine
auto-orders) were corrected rather than left as passing scaffolding.

Assisted-by: ClaudeCode:claude-opus-4.8

* fix(engine): a foreign or phase-unreachable observer does not observe a CR 732.2a loop (CR 117.1b / CR 510.2)

The CR 732.2a object-growth firewall vetoed the loop-shortcut offer for two
classes of bystander that provably cannot observe the loop:

* CR 117.1b + CR 732.2c — no player but the sole driver receives priority
  inside the taken shortcut, so an OPPONENT-controlled activated ability
  cannot be activated during the window. CR 605.3a bounds this: a mana
  ability is activatable outside the priority rule, so it is NOT relieved.
  The relief is per-ABILITY, never per-object.
* CR 510.2 / CR 506.1 (CR 500.1 for the phase list) — a trigger whose event
  is confined to a phase or step the window provably never reaches cannot
  fire inside the loop. `TriggerMode::Phase` with a differing `phase`, plus
  the combat-damage family gated on `damage_kind == CombatOnly`.

Both proofs come from one new authority, `window_scope_from_cover_frames`,
which populates the already-plumbed `LoopWindowScope` at both suppressing
covers. Every guard sits inside `if let Some(..)`, so `unproven()` reaches
none of them and the 2-arg wrappers stay identity
(`scoped_wrappers_are_identity`, unmodified).

Shape pins, both asserted with paired polarities:

* the `Phase` arm is STRICT inequality. Relieving `p == phase` is a
  SOUNDNESS change, not a precision one — CR 117.3a puts beginning-of-phase
  abilities on the stack BEFORE the priority at which CR 732.2a lets a
  shortcut be proposed, and CR 608.2h reads their information at resolution,
  inside the window.
* the damage arm REQUIRES `CombatOnly`; a `damage_kind: Any` trigger fires
  on noncombat damage in any phase.
* every unclassified `TriggerMode` falls to `_ => false` and keeps its veto.

Park Heights Pegasus and Smuggler's Share stop suppressing by the
PHASE/STEP mechanism, not by filter-matching: Pegasus's ledger filter is
`Typed{Creature}`, which genuinely does match a Saproling token. The
mis-attributing in-tree note is corrected and the row renamed accordingly.

Assisted-by: ClaudeCode:claude-opus-4.8

* fix(engine): a conditioned self-cost static in a zone the window never casts from does not observe a CR 732.2a loop (CR 601.2f)

CR 601.2f vs CR 604.1 / CR 613.1: a `StaticMode::ModifyCost` static whose
`affected` is `SelfRef`, sitting on a card the loop window provably never
casts, cannot modify any cost paid inside the window — so its condition's
read of a projected player resource is not an observation of the loop.

The proof is `LoopWindowScope::cast_card_ids`, derived at the projected
cover's conjunct (5) by the new `window_cast_card_ids`. Two soundness
properties, both pinned by tests:

* CR 601.2a — the derivation takes every `LoopActionContext::card_id`
  regardless of `action`, a SUPERSET of the true cast set (only `Recast`
  actually casts). Over-stating the cast set makes `!ids.contains(..)` false
  more often, i.e. FEWER relieved defs. Narrowing to `Recast` is a precision
  upgrade for a successor, with its own paired-polarity row.
* an EMPTY `last_loop_action_sequence` yields `None`, never `Some(vec![])`.
  Empty means NO RECORDED PROOF, not "this window casts nothing"; the latter
  would relieve every conditioned self-cost static. `None` = scan everything.

Extracting the derivation gives the emptiness contract its own directly
callable seam, and a second row drives the real 2-arg cover predicate so the
conjunct-(5) BINDING is pinned too — measured, degrading that binding to
`Some(&[])` re-opens the fail-open while every helper-level row still passes.

Evidence includes the real 4-player Dina/Conqueror capture, whose obj 90
Mortality Spear (library-visible conditioned `ModifyCost`/`SelfRef`) is
measured to be the ONLY projected-resource-reading fire-time surface on the
board, so the flip is attributable to it alone.

`scoped_wrappers_are_identity` is unmodified: the guard sits inside an
`is_some_and`, so `LoopWindowScope::unproven()` never reaches it.

Assisted-by: ClaudeCode:claude-opus-4.8

* fix(engine): a ledger observer whose filter provably cannot count the growing class does not observe a CR 732.2a loop (CR 608.2i)

The CR 732.2a loop-shortcut firewall's growing-class axis vetoes any fire-time
condition that reads a mutable sibling surface. Block (1)'s `execute` scan
vetoed on the read alone, with no look at WHAT the read counts: a trigger whose
body is gated on `BattlefieldEntriesThisTurn { filter }` where the filter
provably cannot match the growing class reads a value that is INVARIANT across
the loop's growth, so it does not observe the loop (CR 608.2h + CR 608.2i).

Narrowing is four fail-closed conjuncts, all required, in
`execute_ledger_condition_provably_excludes_class`:

  (0) `activation_restrictions` must be empty — the firewall is blind to them,
      so a restricted ability fails closed rather than being reasoned about.
  (a) sole-source: a single-field clone with `condition: None` must NOT still
      read a sibling-mutable surface, i.e. the ledger condition is the ONLY
      such read in the ability. No visitor (phase-rs#4603's refusal stands).
  (b) shape: exactly `QuantityCheck { lhs: Ref(BattlefieldEntriesThisTurn),
      rhs: Fixed }` at a single level; everything else `=> false`.
  (c) exclusion: `battlefield_entry_matches_filter` must reject BOTH the
      synthesized entry record for the live class member AND every real
      recorded entry for it. Fail-closed if the member is absent from the
      scanned frame.

MEASURED DELTA, with its population predicate: on today's
`data/card-data.json` the relief population is ZERO — no real card is relieved
by this narrowing. The three cards the directive names are relieved by the
foreign-controller / phase-unreachability narrowings in the two preceding
commits, not by this one. 2c ships a SHAPE, as scaffolding, with that null
delta disclosed rather than papered over. Its soundness residual is likewise
measured and disclosed in the predicate's doc: of `BattlefieldEntryRecord`'s
8 fields the fodder relation (`object_content_eq`, 32 compared fields) covers
only `name` + `controller`, and four of the five uncompared fields are read
verdict-bearingly by a live filter on today's pool — so the residual is
REACHABLE, not latent, and must be re-derived if the card pool is regenerated.

Also extracts `battlefield_entry_record_for` in `game/restrictions.rs` so the
read-only firewall can build an entry record without `&mut GameState`;
`record_battlefield_entry` now calls it, giving the 8-field list one authority.
Behaviour-identical (identical field list, `object_id: obj.id` is the one
mechanically-forced substitution for the removed parameter).

Assisted-by: ClaudeCode:claude-opus-4.8

* style(engine): rewrap the triggers.rs ability import list to rustfmt canonical form

CI "Rust lint (fmt, clippy, parser gate)" failed at its first step,
`cargo fmt --all -- --check`, with a single hunk:

    Diff in crates/engine/src/game/triggers.rs:8:
    -    TriggerDefinitionRef,
    -    TriggerEntry, TriggerGrantProducerKey, TypeFilter, TypedFilter,
    +    TriggerDefinitionRef, TriggerEntry, TriggerGrantProducerKey, TypeFilter, TypedFilter,

The `TriggerDefinitionOccurrenceRef` import added earlier in this chain
pushed the list past a wrap point without being reflowed, leaving a short
line rustfmt joins. Import-list rewrap only — no semantic change.

Assisted-by: ClaudeCode:claude-opus-5

* fix(engine): migrate the 4p retention fixture to the effect_kind contract

Review finding [MED] "New production-fixture regression does not load"
(matthewevans, 2026-07-31): CI Rust shard 1/2 failed loading the four-player
dump at crates/engine/tests/integration/loop_shortcut.rs:4713-4718 with
`missing field effect_kind`, so `two_site_retention_survives_a_prompt_and_its_answer`
never reached the apply pipeline.

Measured cause: the committed blob predates the `effect_kind` serialization
contract. Inflated + key-sorted, the blob differs from its migrated twin by
exactly one line, `+ "effect_kind": "LoseLife"`.

The new blob is extracted BYTE-IDENTICALLY from the already-migrated tree
(`git show <migrated-tip>:<path>`) rather than regenerated by a migration
script, and no `serde(default)` shim was added to `effect_kind`. Byte identity
is the load-bearing property: it keeps this file a no-op when the two histories
are folded, instead of a binary conflict.

Discriminating evidence (both runs local, same test binary):
  - pre-fix blob  => FAILED at loop_shortcut.rs:4717,
    `Error("missing field \`effect_kind\`", line: 0, column: 0)`
  - post-fix blob => ok, 60.41s
The test's existing post-restore reach guards (`state.loop_detection.samples()`
and the block below it) are retained unchanged, per the review's request to
keep a load reach guard.

Assisted-by: ClaudeCode:claude-opus-5

* fix(engine): fail-closed CR 732.2a loop relief and elimination bound

Resolves the review findings on phase-rs#6838.

- Elimination bound is fail-closed: `observed_life_loss.max(0) +
  declared_life_magnitude` replaces `observed.max(declared)`, which admitted an
  unattributed-loss elimination inside a proposed shortcut (CR 732.2a forbids
  conditional actions whose outcome determines the next choice).
- Foreign-observer relief now requires `AbilityKind::Activated` and an absent
  `activator_filter` (CR 602.2): a filtered or non-activated ability is not
  relieved by sole-driver.
- Class-member exclusion is universally quantified at both the ETB and ledger
  sites, with an explicit non-empty guard — `Iterator::all` on an empty set
  returns true, which previously granted relief to every empty class.
- `max_iterations == 0` is rejected rather than clamped; clamping panicked in
  release builds via `Ord::clamp`'s `min <= max` assert.
- Ring invalidation checks snapshot length before `zip`, whose silent
  truncation hid a moved life total.
- Observer-negative rows gain parse reach-guards so a misparse fails loudly
  instead of passing vacuously.
- CR anchors re-seated to the sub-rule each sentence actually claims
  (510.1c->510.1, 702.154a->702.154b, 608.2i->608.2j, 120.3->120.1).

Assisted-by: ClaudeCode:claude-opus-4.8
@keloide
keloide force-pushed the claude/phase-developer-track-owmkmw branch from 3db101a to df8aa13 Compare August 1, 2026 20:15
claude and others added 7 commits August 1, 2026 21:18
… Caravan)

Trade Caravan's activated ability "Remove two currency counters from ~:
Untap target basic land. Activate only during an opponent's upkeep." dropped
its timing restriction to `Effect::Unimplemented`, leaving the ability
activatable at any time.

`parse_activation_during_role_gate` handled "during an opponent's turn" and
"during your upkeep" but not "during an opponent's upkeep". Rather than add a
`DuringOpponents*` sibling to `ActivationRestriction` (which expresses
opponent-turn scope via `RequiresCondition { Not(IsYourTurn) }`, not a
dedicated variant), compose from building blocks:

- Add `ParsedCondition::IsDuringUpkeep` (CR 503.1) — a scope-free upkeep-step
  predicate, orthogonal to every existing `ParsedCondition` variant.
- Map "during an opponent's upkeep" to
  `RequiresCondition { And([Not(IsYourTurn), IsDuringUpkeep]) }` (CR 602.5b +
  CR 102.1 + CR 503.1), reusing the existing opponent-turn composition idiom.
- Evaluate `IsDuringUpkeep` against `state.phase == Phase::Upkeep`; classify it
  memo-safe in the AI condition filter (reads only apply()-constant state).

Removes Trade Caravan from the parser-misparse backlog (root cause 28).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HwvJgpQpXqutChWLLLGB8E
(cherry picked from commit c5024c9)
Review follow-up for the Trade Caravan opponent's-upkeep gate. Three changes,
one per review finding.

1. Team-aware opponent-turn relation (CR 102.3 + CR 805.4a).

`Not(IsYourTurn)` is not "an opponent's turn". `IsYourTurn` compares the scoped
player against the single `active_player` SEAT, but under the shared team turns
option (CR 805.4, always on in Two-Headed Giant per CR 810.1) a turn belongs to
a TEAM. When a teammate holds `active_player` the active team is still the
activator's own team, yet `Not(IsYourTurn)` reported it as an opponent's turn
and opened the activation window.

Add `ParsedCondition::IsOpponentsTurn`, evaluated through the existing
team-aware authority `game::players::is_opponent` — no team logic is
re-derived at the restriction seam. It is not a `PlayerRelation`
parameterization of `IsYourTurn`: the two predicates are asked at different
granularities (seat vs team), so they are independent leaves.

The parser users of this relation are migrated: `opponents_turn_activation_
condition` now emits the new leaf, and the opponent's-upkeep gate composes
`And([IsOpponentsTurn, IsDuringUpkeep])`. The two other enforcement seams that
carried the same non-team-aware test are routed to the same authority:
`CastingRestriction::DuringOpponentsTurn` and
`TriggerConstraint::OnlyDuringOpponentsTurn`.

2. Composed parser grammar instead of whole-clause tags.

`parse_activation_during_role_gate` matched complete phrases, so each new
possessive/role/window spelling was another full-string arm. Replace with
nested prefix dispatch: the shared "during " prefix is consumed once, then a
turn-role axis (`your ` / `their ` / `an opponent's ` / `an opponents `) and a
turn-window axis (`turn` / `upkeep`) are consumed by their own sub-combinators
and mapped through a role x window table. Four role x window gates now come
from four small tags instead of eight enumerated phrases, and both opponent
possessive spellings are retained with per-axis coverage.

3. Regression coverage on the production path.

The previous test called `evaluate_condition` directly, which never proved the
parsed `ActivationRestriction` gates real activation. The tests now parse Trade
Caravan's ability and drive `check_activation_restrictions` (reaching
`activation_restriction_applies`): opposing upkeep allows, own upkeep denies,
opposing non-upkeep denies, and a Two-Headed Giant case denies the upkeep in
which a teammate holds `active_player`. A parser test covers every role x
window pairing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JVhjMYzTr2eC6GG4uVUuGV
Use the existing team-aware opponent authority for opponent upkeep and end-step casting restrictions, with a 2HG production-gate regression across all three opponent scopes.

Co-authored-by: keloide <75585494+keloide@users.noreply.github.com>
@keloide
keloide force-pushed the claude/phase-developer-track-owmkmw branch from df8aa13 to d358967 Compare August 1, 2026 21:32
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.

8 participants