Skip to content

feat(engine): S01 standard-set completion — 11 cards / 7 mechanic groups#4688

Merged
matthewevans merged 9 commits into
phase-rs:mainfrom
lgray:ship/std-s01-completion
Jul 1, 2026
Merged

feat(engine): S01 standard-set completion — 11 cards / 7 mechanic groups#4688
matthewevans merged 9 commits into
phase-rs:mainfrom
lgray:ship/std-s01-completion

Conversation

@lgray

@lgray lgray commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

🤖 AI text below 🤖

Completes the S01 standard-set card cluster — 11 cards across 7 mechanic groups, each landed through an adversarial review + verification gate.

Groups

  • Reflexive object-property conditions (card-type / exact-power / compound or-if) + the current-phase condition — Dose of Dawnglow.
  • Excess-damage "this way" condition (Torch the Witness, Orbital Plunge) + convergence of the DamageDealtThisTurn excess flag onto a typed DamageChannel.
  • Full Bore — target-scoped "cast for its warp cost" condition (CastVariantPaid { subject: ObjectScope::Target }, CR 608.2c / CR 702.185a).
  • ConditionInstead not-swap trailing tail (CR 608.2c) — Throw from the Saddle, Take the Fall, That's Rough Buddy, Evil's Thrall.
  • Kylox's Voltstrider (MKM) — collect-evidence activated ability with the cost charged through the single-authority interactive-cost resolver (fixes a CR 701.59 free-animate), plus the cast-from-exile library-bottom rider.
  • Quistis Trepe + Tinybones the Pickpocket — cast-from-graveyard with any-type mana (CR 609.4b), multiplayer-correct target (Owned { TriggeringPlayer }).
  • Summoner's Grimoire (MKM) — conditional enters-tapped-and-attacking on a granted put-from-hand, a zero-new-variant leaf parameterization of the ChangeZone enters-modifier cluster (enters_modified_if, CR 614.12).

Verification (rebased on upstream/main @ 2222dbeed)

  • cargo fmt --all --check: clean
  • cargo clippy --workspace --all-targets --features engine/proptest -D warnings: 0 warnings
  • cargo test -p engine --features proptest: 16531 passed, 0 failed (15 ignored). All 7 load-bearing S01 tests pass.
  • card-data coverage-regression (built HEAD and a 2222dbeed baseline against the same MTGJSON + identical committed known-tokens.toml, so the diff is purely parser-source):
    • REGRESSED (engine) = 0 — fatal CI gate passes.
    • ORACLE CHANGED = 0 (clean parser-only diff).
    • GAINED = 19 — 10 named S01 cards flipped false → true plus 9 class-level bonus unlocks; Tinybones + Evil's Thrall were already supported and were improved without a coverage flip.
    • 1 non-fatal coverage-honesty flip (Masterful Ninja) — HEAD honestly flags a nonsensical clause the baseline silently swallowed (swallowed-clause diagnostic 969 → 947).

@lgray lgray requested a review from matthewevans as a code owner July 1, 2026 02:56
@gemini-code-assist

Copy link
Copy Markdown
Contributor

Warning

You have reached your daily quota limit. Please wait up to 24 hours and I will start processing your requests again!

@lgray lgray force-pushed the ship/std-s01-completion branch from 476e049 to b1f78e0 Compare July 1, 2026 03:03

@matthewevans matthewevans left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[HIGH] Excess-damage fight conditions read the wrong fighter. Evidence: crates/engine/src/game/effects/mod.rs:5037 selects the first object target of the fight when computing PreviousEffectAmount excess, but the parser accepts text like "the creature the opponent controls is dealt excess damage this way" where the recipient is the second fight target. Why it matters: dual-target fight cards such as The Last Agni Kai can add mana based on excess damage to the controller's creature instead of the opponent's creature. Suggested fix: carry the parsed damage-recipient subject into the lowered condition, or make the fight excess resolver select the actual recipient named by the condition, with a dual-target runtime test.

[HIGH] Full-cost "you may cast ... from graveyard" effects lower to a delayed permission instead of a during-resolution cast. Evidence: crates/engine/src/parser/oracle_effect/mod.rs:16884 turns Quistis/Tinybones-style text into Effect::CastFromZone, and crates/engine/src/game/effects/cast_from_zone.rs:297-:308 only casts during resolution for free-cast/graveyard-free-cast paths; otherwise it grants lingering permissions. Why it matters: these triggers are one-shot casts during resolution, but the PR changes timing and can allow/deny spells based on a later priority window instead. Suggested fix: implement a paid during-resolution cast path that uses the real mana cost plus mana_spend_permission, or keep this text unsupported until that path exists.

[MED] Graveyard replacement riders can leak from an unconsumed cast permission. Evidence: crates/engine/src/game/casting_costs.rs:5832-:5841 scans any ExileWithAltCost { graveyard_replacement: Some(..) } on the object instead of the selected permission that authorized the current cast, while the adjacent enters-with-counter path uses selected-permission authority at :5851-:5856. Why it matters: a stale or foreign permission can redirect the resolving spell to exile/library/hand even when the current cast did not use that rider. Suggested fix: add a selected-permission helper analogous to selected_exile_alt_cost_permission_enters_with_counter and use it when installing apply_spell_graveyard_replacement_rider.

[MED] Legacy serialized excess-damage quantities lose their excess-only gate. Evidence: crates/engine/src/types/ability.rs:4649 replaces the former excess_only bool with a defaulted channel: DamageChannel, but there is no alias/compat deserializer mapping old excess_only: true payloads to DamageChannel::Excess. Why it matters: old card data/saves/scenarios reload as total-damage checks and can fire on any matching damage. Suggested fix: add serde compatibility for the old bool field and a legacy JSON test for excess_only: true.

[MED] Legacy serialized ExileWithAltCost permissions lose their exile-on-resolution rider. Evidence: crates/engine/src/types/ability.rs:2118 replaces exile_instead_of_graveyard_on_resolve with defaulted graveyard_replacement, but there is no alias/compat mapping from the old bool to Some(SpellStackToGraveyardReplacement::Exile). Why it matters: in-flight saved/reconnected permissions reload without the required graveyard replacement and resolve to the graveyard. Suggested fix: add serde compatibility for the old bool field and cover it with a legacy CastingPermission round-trip test.

@github-actions

github-actions Bot commented Jul 1, 2026

Copy link
Copy Markdown

Parse changes introduced by this PR · 35 card(s), 45 signature(s) (baseline: main 17df2c8aa48b)

4 card(s) · ability/AdditionalPhase · field conditional: current phase matches and is your turn

Examples: All-Out Assault, Grim Reaper's Sprint, Moraug, Fury of Akoum (+1 more)

2 card(s) · ability/Draw · added: Draw

Examples: Take the Fall, That's Rough Buddy

2 card(s) · ability/PutCounter · field counter: DamageDealtThisTurn { source: Any, target: And { filters: [Player, Typed(TypedFilter { type_filters: [], controller: So…DamageDealtThisTurn { source: Any, target: And { filters: [Player, Typed(TypedFilter { type_filters: [], controller: So…

Examples: Indoraptor, the Perfect Hybrid, Petrified Wood-Kin

2 card(s) · ability/SpendManaAsAnyColor · removed: SpendManaAsAnyColor (target=controller)

Examples: Quistis Trepe, Tinybones, the Pickpocket

2 card(s) · ability/Token · field conditional: previous excess amount > 0

Examples: Bottle-Cap Blast, Orbital Plunge

2 card(s) · ability/note · added: note (conditional=previous excess amount > 0)

Examples: Mephit's Enthusiasm, Molten Impact

1 card(s) · ability/BlightEffect · field conditional: not (current phase matches and is your turn)

Examples: Dose of Dawnglow

1 card(s) · ability/CastFromZone · added: CastFromZone (target=in graveyard instant or sorcery)

Examples: Quistis Trepe

1 card(s) · ability/CastFromZone · added: CastFromZone (target=in graveyard triggering player controls permanent non-land)

Examples: Tinybones, the Pickpocket

1 card(s) · ability/ChangeZone · added: ChangeZone (from=graveyard, kind=activated, target=mv X- in graveyard you control creature, timing=sorcery speed, to=battlefield)

Examples: Blighted Nightmare

1 card(s) · ability/ChangeZone · field conditional: target is permanent

Examples: Yarus, Roar of the Old Gods

1 card(s) · ability/ChangeZone · field conditional: target is toughness ≥1

Examples: Hook Horror

1 card(s) · trigger/ChangesZone · field optional: yes

Examples: Quistis Trepe

1 card(s) · trigger/DamageDone · field optional: yes

Examples: Tinybones, the Pickpocket

1 card(s) · ability/DealDamage · added: DealDamage (amount=self power, target=opponent controls creature)

Examples: Throw from the Saddle

1 card(s) · ability/Destroy · field conditional: referenced object's power ≤ 0target is power ≤0

Examples: Depressurize

1 card(s) · ability/DestroyAll · field conditional: self power = 20

Examples: Amalia Benavides Aguirre

1 card(s) · ability/Discard · field conditional: previous excess amount > 0

Examples: Intruder's Inquisition

1 card(s) · ability/Discover · added: Discover (conditional=previous excess amount > 0, mv limit=Ref { qty: Variable { name: "that excess damage" } }, player=Controller)

Examples: Contest of Claws

1 card(s) · ability/Draw · field conditional: referenced object's power ≤ 2target is power ≤2

Examples: Eagle of Deliverance

1 card(s) · ability/Draw · field conditional: referenced object's power ≥ 4target is power ≥4

Examples: Aradesh, the Founder

1 card(s) · ability/Draw · field conditional: previous excess amount > 0

Examples: Vikya, Scorching Stalwart

1 card(s) · ability/GainControl · field conditional: target is power ≤2 or # of another in battlefield you control Lizard ≥ 1

Examples: Reptilian Recruiter

1 card(s) · ability/Investigate · field conditional: previous excess amount > 0

Examples: Torch the Witness

1 card(s) · ability/Mana · field conditional: previous amount > 0previous excess amount > 0

Examples: The Last Agni Kai

… 20 more signature(s) (20 card-changes) — see parse-diff.json
  • 1 card(s) · ability/Pump · field conditional: instead if (# of in battlefield you control Assassin or Mercenary or Pirate or Rogue or Warlock ≥ 1)
  • 1 card(s) · ability/Pump · field target: parent targetself
  • 1 card(s) · ability/Pump · removed: Pump (p/t=+the noted number."/+0, target=parent target)
  • 1 card(s) · ability/PutAtLibraryPosition · field count: Fixed { value: 0 }Fixed { value: 1 }
  • 1 card(s) · ability/PutAtLibraryPosition · field target: cards exiled by sourceparent target
  • 1 card(s) · ability/PutAtLibraryPosition · removed: PutAtLibraryPosition (count=Fixed { value: 0 }, position=Bottom, target=cards exiled by source)
  • 1 card(s) · ability/PutCounter · field conditional: instead if (target is Mount)
  • 1 card(s) · ability/PutCounter · field conditional: instead if (you control creature zone changes this turn (Some(Battlefield)->None) ≥ 1)
  • 1 card(s) · ability/PutCounter · field target: selfparent target
  • 1 card(s) · ability/Regenerate · field conditional: referenced object's toughness ≥ 1target is toughness ≥1
  • 1 card(s) · ability/RevealHand · removed: RevealHand (card filter=none, duration=until end of turn, player=any target)
  • 1 card(s) · ability/TurnFaceUp · added: TurnFaceUp
  • 1 card(s) · ability/Untap · added: Untap (conditional=target is power ≤2 or # of another in battlefield you control Lizard ≥ 1, target=self)
  • 1 card(s) · ability/add type artifact, add type creature · added: add type artifact, add type creature (affects=self, duration=until end of turn, grants=add type artifact, grants=add type creature, kind=activated)
  • 1 card(s) · ability/discover · removed: discover
  • 1 card(s) · ability/grant Trample, grant Haste · field conditional: cast variant was paid
  • 1 card(s) · ability/note · removed: note
  • 1 card(s) · ability/turn · added: turn
  • 1 card(s) · ability/unknown · removed: unknown
  • 1 card(s) · ability/~ · added: ~ (duration=until end of turn, kind=activated)

Completes the S01 standard-set card cluster. Groups (each landed through an
adversarial review + verification gate):

- Reflexive object-property conditions (card-type / exact-power / compound
  or-if) and the current-phase condition (Dose of Dawnglow).
- Excess-damage "this way" condition (Torch the Witness, Orbital Plunge) +
  convergence of the DamageDealtThisTurn excess flag onto a typed DamageChannel.
- Full Bore — target-scoped "cast for its warp cost" condition.
- ConditionInstead not-swap trailing tail (CR 608.2c) — Throw from the Saddle,
  Take the Fall, That's Rough Buddy, Evil's Thrall.
- Kylox's Voltstrider (MKM) — collect-evidence activated ability with the cost
  charged through the single-authority interactive-cost resolver (fixes a
  CR 701.59 free-animate), plus the cast-from-exile library-bottom rider.
- Quistis Trepe + Tinybones the Pickpocket — cast-from-graveyard with any-type
  mana (CR 609.4b), multiplayer-correct target (Owned{TriggeringPlayer}).
- Summoner's Grimoire (MKM) — conditional enters-tapped-and-attacking on a
  granted put-from-hand, a zero-new-variant leaf parameterization of the
  ChangeZone enters-modifier cluster (enters_modified_if, CR 614.12).

Assisted-by: ClaudeCode:claude-opus-4.8
@lgray lgray force-pushed the ship/std-s01-completion branch from b1f78e0 to e21efa8 Compare July 1, 2026 03:15

@matthewevans matthewevans left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Thanks for the quick follow-up. I re-reviewed the current head (e21efa8890aa0c36ed57d586d84adf0b69c889c8) and the main blockers from the previous review are still present.

[HIGH] The fight excess-damage condition still reads the wrong fighter. previous_effect_excess_amount_from_events still selects the first object target on the Fight ability (crates/engine/src/game/effects/mod.rs:5037-5040) and then sums excess damage dealt to that object (:5041-5056). For templates like The Last Agni Kai, the first target is the source creature, but the condition is about excess damage dealt to the opponent's creature, so the condition can pass/fail on the wrong damage event.

[HIGH] Full-cost resolution-time graveyard casts still become lingering permissions. The parser now forwards ManaSpendPermission::AnyTypeOrColor, but handle_cast_from_zone only performs an immediate graveyard cast when without_paying_mana_cost is true (crates/engine/src/game/effects/cast_from_zone.rs:288-297). Quistis/Tinybones-style text says to cast the targeted graveyard card during resolution while paying its normal cost, so the non-free path still falls through to grant_lingering_permissions at :308 instead of creating the required during-resolution paid-cast choice.

[MED] The graveyard replacement rider is still read from any permission on the object, not the permission consumed by this cast. finalize_cast_with_phyrexian_choices still scans obj.casting_permissions.iter().find_map(...) for the first ExileWithAltCost { graveyard_replacement: Some(...) } (crates/engine/src/game/casting_costs.rs:5832-5840). That can apply another grant's replacement rider to this cast; it needs the same selected-permission authority used by selected_exile_alt_cost_permission_enters_with_counter.

[MED] The serialized-state migration gaps are still present. DamageDealtThisTurn now defaults channel to total (crates/engine/src/types/ability.rs:4644-4650), and ExileWithAltCost now defaults graveyard_replacement to None (:2118-2119), but there is no compatibility path for legacy excess_only: true or exile_instead_of_graveyard_on_resolve: true. Existing saved/generated JSON using those fields will deserialize as total damage / no replacement rider.

@matthewevans matthewevans self-assigned this Jul 1, 2026
…subject

The Last Agni Kai dual-target fight ("target creature you control fights
target creature an opponent controls. If the creature the opponent controls
is dealt excess damage this way, add that much R") put both fighters in
ability.targets as [subject, fought]. Both previous_effect_amount_from_events
and previous_effect_excess_amount_from_events blindly find_map the FIRST
object target (the subject), summing excess dealt to the wrong creature.

Extract resolve_fight_fighters as the single authority for the (subject,
fought-creature) split (also de-duplicating two identical branches in
fight::resolve) and read the recipient from it in both amount functions.

CR 120.10 + CR 701.14a. Adds a discriminating dual-target runtime test.
# Conflicts:
#	crates/engine/src/game/casting_tests.rs
… compat for legacy bools

MED (graveyard rider): finalize_cast_with_phyrexian_choices read the
graveyard-redirect rider from the FIRST ExileWithAltCost permission carrying
one, so a non-consumed sibling permission's rider could leak onto this cast.
Add selected_exile_alt_cost_permission_graveyard_replacement (mirrors the
enters-with-counter selected-permission authority) and use it. CR 608.2c.

MED (serde compat): the channel: DamageChannel field (replacing excess_only:
bool) and graveyard_replacement: Option<...> (replacing
exile_instead_of_graveyard_on_resolve: bool) had no compatibility path for
pre-migration serialized state. Add serde alias + compat deserializers so
legacy excess_only:true -> Excess and exile_instead_of_graveyard_on_resolve:true
-> Some(Exile), with legacy-JSON round-trip tests. CR 120.10 / CR 608.2n.
…09.4b)

"You may cast target X card from a graveyard, and mana of any type can be spent
to cast that spell" (Quistis Trepe, Tinybones the Pickpocket) was lowering to a
lingering ExileWithAltCost permission — wrong CR 608.2g timing, and inert for
Tinybones' opponent-graveyard target (phase-rs#2884). Make it a during-resolution
one-shot PAID cast, offered accept/decline as the trigger resolves.

- Parameterize the during-resolution cast authority with a typed
  ResolutionCastCost { Free, FullCost { mana_spend_permission } } instead of
  hardcoded zero/None/Auto. The 5 existing free-cast callers (Cascade, Discover,
  Ripple, FreeCastWindow, the shared during-resolution caller) pass Free and are
  byte-identical.
- New CastOfferKind::GraveyardPaidCast + GameAction::GraveyardPaidCastChoice. A
  router paid-gate in cast_from_zone::resolve opens the offer; accept casts at
  the card's real printed cost (SelfManaCost) with the CR 609.4b any-type-mana
  concession; decline leaves the card in the graveyard.
- Parser sets driver = DuringResolution in try_parse_cast_target_from_graveyard_any_mana
  so the router routes to the offer rather than the lingering path.
- Re-home the concession-only ExileWithAltCost past finalize_cast's
  cascade-constraint gate so the CR 609.4b any-type concession survives to the
  mana-payment step (off-color payment would otherwise fail).
- Cross-crate exhaustiveness arms (phase-ai forced_action -> Decline, engine
  source_object -> None, server-core payload guard no-op); AI accept/decline
  candidates; scenario label; frontend paid-cast offer modal + i18n (7 locales).

6 new discriminating runtime tests drive the real apply() pipeline (router opens
offer not lingering permission; accept opens manual payment then stack; off-color
pay via any-type concession; decline; free-cast still Auto; without_paying bypass)
plus reconciled Quistis/Tinybones/swallow_check assertions.
…ars it

Kylox's Voltstrider casts a card from exile with a "put it on the bottom of its
owner's library instead" rider, but finalize_cast read the rider AFTER the
Exile->Stack move — and apply_zone_exit_cleanup drops every ExileWithAltCost
permission when a card leaves exile (CR 400.7 / CR 113.6e), so the rider was
gone by the read and the spell wrongly went to the graveyard.

Capture the graveyard_replacement destination into a local BEFORE the
casting_to_stack move (mirroring the sibling exile-scoped captures already taken
pre-move for the same reason), and apply it after the move. Behavior-neutral for
graveyard/hand in-place casts (Quistis, Emry, Electrodominance — their
permission survives the move). No double-install with the during-resolution paid
path: evaluate_cascade_constraint_with_resulting_mv strips the
resolution_cleanup permission before the finalize read, so the two
rider-application points (initiate_cast_during_resolution vs finalize) are
mutually exclusive per cast.

CR 614.1a / CR 608.2n. Adds an exile-origin end-to-end test (bottoms the
resolved spell instead of graveyarding it) and a during-resolution guard
asserting exactly one redirect installs.
…ss and Orbital Plunge

The excess-damage "this way" channel class was covered, but neither card had a
test driving its own Oracle text through a real cast. Add per-card overkill vs
exact-lethal runtime tests: the excess-gated follow-up (Investigate clue /
Lander token) fires only when excess > 0, discriminating the Excess channel
from Total (a Total-summing resolver would wrongly fire on the exact-lethal
case). CR 120.10.
@matthewevans matthewevans added feature Larger-scoped feature area:engine Core rules engine labels Jul 1, 2026

@matthewevans matthewevans left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Maintainer review: passes the authorized review bar at head 10be31afa74eb17a1345adbf420c348ec7c676db.

@matthewevans matthewevans added this pull request to the merge queue Jul 1, 2026
@github-merge-queue github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Jul 1, 2026
@matthewevans matthewevans enabled auto-merge July 1, 2026 18:31
@matthewevans matthewevans added this pull request to the merge queue Jul 1, 2026
Merged via the queue into phase-rs:main with commit f70323d Jul 1, 2026
13 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:engine Core rules engine feature Larger-scoped feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants