Fix Trade Caravan - #5
Open
keloide wants to merge 14 commits into
Open
Conversation
Parse changes introduced by this PR · 52 card(s), 40 signature(s) (baseline: main
|
keloide
force-pushed
the
claude/phase-developer-track-owmkmw
branch
from
July 29, 2026 14:54
315ee0e to
d56780a
Compare
* 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
force-pushed
the
claude/phase-developer-track-owmkmw
branch
from
August 1, 2026 20:15
3db101a to
df8aa13
Compare
… 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
force-pushed
the
claude/phase-developer-track-owmkmw
branch
from
August 1, 2026 21:32
df8aa13 to
d358967
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 toEffect::Unimplemented, leaving the ability activatable at any time.parse_activation_during_role_gatehandledduring an opponent's turnandduring your upkeepbut had no arm forduring an opponent's upkeep. This adds that arm by composing existing building blocks rather than proliferating a newDuringOpponents*restriction sibling.Implementation method (required)
Method: /engine-implementer (gates applied inline:
add-engine-variantexistence/parameterization/boundary gate + mandatory finalreview-impl)CR references
CR 503.1— the upkeep step (newParsedCondition::IsDuringUpkeeppredicate + its evaluator arm).CR 102.1— the active player is the player whose turn it is (opponent scope viaNot(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
DuringOpponentsUpkeepsibling toActivationRestriction(which already expresses opponent-turn scope viaRequiresCondition { Not(IsYourTurn) }, not a dedicatedDuringOpponentsTurnvariant), the fix composes:ParsedCondition::IsDuringUpkeep— a scope-free upkeep-step predicate, orthogonal to every existingParsedConditionvariant (there was no prior phase predicate).during an opponent's upkeep→RequiresCondition { And([Not(IsYourTurn), IsDuringUpkeep]) }, reusing the same opponent-turn composition idiom as the existing bare opponent-turn gate.add-engine-variantgate result forParsedCondition::IsDuringUpkeep: Stage 1 DOES_NOT_EXIST (no phase predicate inParsedCondition), 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 exhaustiveParsedConditionmatch is satisfied).cargo test -p engine --lib—ok. 17440 passed; 0 failed; 6 ignored.cargo test -p engine --test integration—ok. 3676 passed; 0 failed; 2 ignored.New tests:
activated_ability_opponents_upkeep_restriction_composes_scope_and_step(parser building block, drives productionparse(), asserts composed restriction + zeroEffect::Unimplemented) andopponents_upkeep_activation_condition_gates_on_step_and_scope(runtime, drives realevaluate_conditionacross the full turn-scope × step matrix) — both pass.cargo coverage/cargo semantic-audit— deferred to CI: both require a full MTGJSONcard-data.jsonexport (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 zeroEffect::Unimplementedand the correctRequiresCondition { And([Not(IsYourTurn), IsDuringUpkeep]) }restriction. Sincecard_face_has_unimplemented_parts(game/coverage.rs) derivessupportedprecisely from the absence ofUnimplementedparts, Trade Caravan is nowsupported: true, gap_count: 0.Gate A
Gate A PASS head=c5024c97678be3ffdcbac252e235cd8ed145fdab base=ee061e5c7485619644dc6089976b94a1d29a0202
Anchored on
crates/engine/src/parser/oracle.rs:6792—value(opponents_turn_activation_restriction(), alt((tag("during an opponent's turn"), tag("during an opponents turn")))): the samevalue(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:6803—value(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.crates/engine/src/game/restrictions.rs:1520ParsedCondition::IsYourTurn => state.active_player == playerandrestrictions.rs:1066CastingRestriction::DuringOpponentsUpkeep => state.active_player != player && state.phase == Phase::Upkeep— the evaluator arms the newIsDuringUpkeep => state.phase == Phase::Upkeeparm mirrors.Final review-impl
Final review-impl PASS head=c5024c97678be3ffdcbac252e235cd8ed145fdab
Claimed parse impact
docs/parser-misparse-backlog.mdroot cause 28)🤖 Generated with Claude Code
Generated by Claude Code