Skip to content

Fix #594: ExileCastPermission static for Maralen-class cast-from-exile - #1404

Merged
matthewevans merged 3 commits into
phase-rs:mainfrom
mike-theDude:bugfix/594-maralen-cast-from-exile
May 29, 2026
Merged

Fix #594: ExileCastPermission static for Maralen-class cast-from-exile#1404
matthewevans merged 3 commits into
phase-rs:mainfrom
mike-theDude:bugfix/594-maralen-cast-from-exile

Conversation

@mike-theDude

Copy link
Copy Markdown
Collaborator

Summary

Follow-up to #1401 — closes the remaining gap on #594 (defects #4 and #5 in the issue). Adds the new StaticMode::ExileCastPermission static and the runtime support for Maralen, Fae Ascendant's "Once each turn, you may cast a spell with mana value less than or equal to the number of Elves and Faeries you control from among cards exiled with Maralen this turn without paying its mana cost."

#1401 fixed the trigger half (count + target-opponent controller on the ETB exile). This PR fixes the static half: the second ability is no longer Unimplemented and now lowers to a typed static that the casting flow consumes end-to-end.

Files changed

  • crates/engine/src/types/statics.rs
  • crates/engine/src/types/game_state.rs
  • crates/engine/src/parser/oracle_static.rs
  • crates/engine/src/parser/swallow_check.rs
  • crates/engine/src/game/casting.rs
  • crates/engine/src/game/casting_costs.rs
  • crates/engine/src/game/coverage.rs
  • crates/engine/src/game/exile_links.rs
  • crates/engine/src/game/turns.rs

CR references

  • CR 601.2a — cast-permission grants (ExileCastPermission, CastingVariant::ExilePermission)
  • CR 113.6b — zone-restricted ability functioning (per-turn pool scoping)
  • CR 118.9 / CR 118.9a — alternative cost / "without paying its mana cost"
  • CR 305.1 — Play vs Cast play-mode axis
  • CR 400.7 — zone change resets the source ObjectId and per-turn slot
  • CR 611.2a — permission-granting effect controller binding
  • CR 201.5 — self-reference token (~) handling in parser

All numbers grepped against docs/MagicCompRules.txt before annotation.

Track

Developer

LLM

Model: claude-opus-4-7
Thinking: medium

Tier

Frontier

Anchored on

  • crates/engine/src/types/statics.rs:576 — existing GraveyardCastPermission variant (mirrored shape: frequency + play_mode + optional rider; my ExileCastPermission follows the same axes)
  • crates/engine/src/game/casting.rs:1079 — existing graveyard_objects_castable_by_permission helper (mirrored by exile_objects_castable_by_permission at the same level of abstraction, with the pool source swapped from player.graveyard to cards_exiled_with_source_this_turn[source_id])
  • crates/engine/src/parser/oracle_static.rs:10157 — existing try_parse_graveyard_cast_permission parser (mirrored anchor pattern: frequency-prefix nom_tag_lower + class-defining split anchor + parse_type_phrase for the affected filter)
  • crates/engine/src/game/exile_links.rs:7 — existing LINKED_EXILE_CONSUMER_TAGS (extended with "ExileCastPermission" so source-level consumer detection picks up Maralen without any special-casing)

Verification

  • cargo fmt --all — clean
  • cargo clippy -p engine --all-targets -- -D warnings — clean
  • ./scripts/check-parser-combinators.sh — clean (exit 0)
  • cargo test -p engine --lib — 9077 pass / 0 fail (was 9070; 7 new tests added)
  • cargo test -p phase-ai — 710 pass / 0 fail
  • ./scripts/gen-card-data.sh — Maralen, Fae Ascendant: 0 Unimplemented entries; static_abilities = [{ ExileCastPermission { frequency: OncePerTurn, play_mode: Cast, without_paying_mana_cost: true } }] with the dynamic CMC predicate Cmc(LE, ObjectCount{Elf|Faerie, You})

Parser tests added (oracle_static.rs)

  • exile_cast_permission_maralen_fae_ascendant — full Maralen text round-trips through parse_static_line to the typed ExileCastPermission + dynamic-CMC predicate
  • exile_cast_permission_during_each_of_your_turns_synonym — accepts the longer "once during each of your turns" synonym (built for the class, not the card)
  • exile_cast_permission_rejects_missing_this_turn_suffix — guards the structural "this turn" anchor so persistent-link cards (Court of Locthwain, Bag of Holding, …) don't get misclassified
  • exile_cast_permission_not_intercepted_by_graveyard_branch — regression guard against the sibling graveyard handler over-anchoring on "you may cast"

Engine tests added (casting.rs)

  • exile_cast_permission_surfaces_pool_card — the per-turn pool + active static surface the exiled card through spell_objects_available_to_cast
  • exile_cast_permission_once_per_turn_frequency_gates_offer — consumed OncePerTurn slot prunes the card; turn-cleanup reset (clearing exile_cast_permissions_used) restores it
  • exile_cast_permission_rejects_card_outside_per_turn_pool — cards exiled in a prior turn (not in the per-turn map) are correctly NOT surfaced, validating the "this turn" scoping

Scope Expansion

None.

Validation Failures

None.

CI Failures

None.

Refs #594. Builds on #1401 (independent — no rebase needed; both PRs may merge in any order).

…exile

Closes the remaining gap on phase-rs#594 left after phase-rs#1401 (count + controller
fix on the trigger half). Adds the engine support for "Once each turn,
you may cast a spell ... from among cards exiled with ~ this turn
without paying its mana cost." — defects phase-rs#4 (per-turn exile-link
scoping) and phase-rs#5 (cast-from-exile permission static) in the issue.

New `StaticMode::ExileCastPermission { frequency, play_mode,
without_paying_mana_cost }` mirrors `GraveyardCastPermission` for the
exile-pool sibling. Runtime additions:

- `GameState::cards_exiled_with_source_this_turn` — per-source per-turn
  rolling list, populated alongside `exile_links::push_tracked_by_source`
  and cleared at turn cleanup. Keeps the persistent `exile_links` pool
  untouched (still backs the open-ended `ExiledBySource` filter).
- `GameState::exile_cast_permissions_used` — per-source `OncePerTurn`
  slot, mirroring `graveyard_cast_permissions_used`.
- `CastingVariant::ExilePermission { source, frequency }` — casting
  context that finalize-cast consults to stamp the slot.
- `casting.rs::exile_objects_castable_by_permission` +
  `exile_cast_permission_source` extend
  `spell_objects_available_to_cast` and `has_exile_cast_permission`.
- `casting.rs::is_exile_permission_free_cast` zeroes the mana cost
  when the static carries `without_paying_mana_cost: true`.
- `exile_links::LINKED_EXILE_CONSUMER_TAGS` gains `"ExileCastPermission"`
  so a Maralen source is auto-detected as a tracked-exile consumer; her
  ETB trigger then populates exile_links + the per-turn map for free.
- Parser handler in `oracle_static.rs::try_parse_exile_cast_permission`
  uses the shared nom combinator chain — `parse_type_phrase` already
  composes the dynamic "with mana value …" suffix through
  `parse_mana_value_suffix`, so Maralen's filter reaches `Cmc(LE,
  ObjectCount{Elf|Faerie, You})` through one call.

CR annotations: 601.2a (cast permission grant), 113.6b (zone-restricted
functioning), 118.9 (alternative cost), 400.7 (zone-change resets the
source ObjectId / per-turn slot), 305.1 (play vs cast). All verified
against docs/MagicCompRules.txt.

Tests:
- Parser: Maralen full line, longer "once during each of your turns"
  synonym, rejects missing "this turn" suffix, regression-guarded
  against the graveyard branch intercepting.
- Engine: surface the per-turn pool, OncePerTurn slot gates and resets,
  cards outside the per-turn map (stale exile from prior turn) are
  pruned.

Branch: bugfix/594-maralen-cast-from-exile

Refs phase-rs#594.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@github-actions github-actions Bot added ai-contribution PR opened via docs/AI-CONTRIBUTOR.md flow needs-maintainer AI-contribution PR requires human triage (Non-dev track or unresolved gaps) labels May 29, 2026

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

This pull request implements the 'StaticMode::ExileCastPermission' static ability to support casting cards from exile (such as for Maralen, Fae Ascendant), including parser support, game state tracking for per-turn usage, and comprehensive tests. The review feedback highlights a violation of Rule R2 of the Repository Style Guide, recommending that the boolean field 'without_paying_mana_cost' in 'StaticMode::ExileCastPermission' be replaced with a typed enum to better represent the design space.

Comment thread crates/engine/src/types/statics.rs Outdated
/// cast at its normal cost (no published printings in this class today,
/// but the field keeps the static composable with future patterns).
#[serde(default)]
without_paying_mana_cost: bool,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

[MEDIUM] Avoid using a bool field for without_paying_mana_cost in StaticMode::ExileCastPermission.

Why it matters: Rule R2 of the Repository Style Guide states that bool fields should not be used to express the design space. Instead, typed enums should be used to carry the same information with more meaning and extensibility.

Suggested fix: Introduce a typed enum (e.g., CastingCostModifier or CastingCostSpecification) with variants like Normal and WithoutPayingManaCost to replace the boolean field.

References
  1. Rule R2: No bool fields — parameterize with existing typed enums. A bool field never expresses the design space; the project uses typed enums instead. (link)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in ac289b0: replaced the without_paying_mana_cost: bool field with a typed ExileCastCost enum (PayNormalCost | WithoutPayingManaCost, defaulting to WithoutPayingManaCost). All call sites (parser, casting source detection, free-cast derivation, tests) and the Display/FromStr round-trip were updated; cargo test -p engine --lib passes 9077/0, and Maralen lowers to { "cost": "WithoutPayingManaCost" }.

Replaces the `without_paying_mana_cost: bool` field on
StaticMode::ExileCastPermission with a typed `ExileCastCost` enum
(PayNormalCost | WithoutPayingManaCost), per CLAUDE.md rule R2 (no
raw bool fields — use typed enums that express the design space).

Addresses Gemini review comment on PR phase-rs#1404.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@matthewevans
matthewevans added this pull request to the merge queue May 29, 2026
@matthewevans
matthewevans removed this pull request from the merge queue due to a manual request May 29, 2026
exile_permission_sources scans the whole battlefield and allocates an
active_static_definitions iterator per controlled permanent. Both
exile_objects_castable_by_permission (once per legal-actions / AI-search
node) and exile_cast_permission_source (per-object castability predicate,
casting.rs:829) call it on the hot path.

Guard both with a single cards_exiled_with_source_this_turn.is_empty()
check: a card is castable-via-permission only if it was exiled-with-a-
source this turn (it must live in that pool), so an empty pool provably
yields no offers. Skips the scan in the ~100% of board states with no
Maralen-class permanent active; output is identical.

Add start_next_turn_resets_exile_cast_permission_tracking: a discriminating
regression test that drives start_next_turn and fails if either per-turn
reset line (exile_cast_permissions_used / cards_exiled_with_source_this_turn)
is dropped.
@matthewevans
matthewevans added this pull request to the merge queue May 29, 2026
Merged via the queue into phase-rs:main with commit c5afdfe May 29, 2026
9 checks passed

@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-reviewed at the quality bar: logic traced end-to-end, test discrimination checked, architecture (combinators / parameterization / building-block reuse / CR annotations) verified.

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

Labels

ai-contribution PR opened via docs/AI-CONTRIBUTOR.md flow enhancement New feature or request needs-maintainer AI-contribution PR requires human triage (Non-dev track or unresolved gaps)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants