Fix #594: ExileCastPermission static for Maralen-class cast-from-exile - #1404
Conversation
…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>
There was a problem hiding this comment.
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.
| /// 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, |
There was a problem hiding this comment.
[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
- 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)
There was a problem hiding this comment.
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>
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
left a comment
There was a problem hiding this comment.
Maintainer-reviewed at the quality bar: logic traced end-to-end, test discrimination checked, architecture (combinators / parameterization / building-block reuse / CR annotations) verified.
Summary
Follow-up to #1401 — closes the remaining gap on #594 (defects #4 and #5 in the issue). Adds the new
StaticMode::ExileCastPermissionstatic 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
Unimplementedand now lowers to a typed static that the casting flow consumes end-to-end.Files changed
CR references
ExileCastPermission,CastingVariant::ExilePermission)~) handling in parserAll numbers grepped against
docs/MagicCompRules.txtbefore annotation.Track
Developer
LLM
Model: claude-opus-4-7
Thinking: medium
Tier
Frontier
Anchored on
GraveyardCastPermissionvariant (mirrored shape:frequency+play_mode+ optional rider; myExileCastPermissionfollows the same axes)graveyard_objects_castable_by_permissionhelper (mirrored byexile_objects_castable_by_permissionat the same level of abstraction, with the pool source swapped fromplayer.graveyardtocards_exiled_with_source_this_turn[source_id])try_parse_graveyard_cast_permissionparser (mirrored anchor pattern: frequency-prefixnom_tag_lower+ class-defining split anchor +parse_type_phrasefor the affected filter)LINKED_EXILE_CONSUMER_TAGS(extended with"ExileCastPermission"so source-level consumer detection picks up Maralen without any special-casing)Verification
cargo fmt --all— cleancargo 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 predicateCmc(LE, ObjectCount{Elf|Faerie, You})Parser tests added (
oracle_static.rs)exile_cast_permission_maralen_fae_ascendant— full Maralen text round-trips throughparse_static_lineto the typedExileCastPermission+ dynamic-CMC predicateexile_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 misclassifiedexile_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 throughspell_objects_available_to_castexile_cast_permission_once_per_turn_frequency_gates_offer— consumedOncePerTurnslot prunes the card; turn-cleanup reset (clearingexile_cast_permissions_used) restores itexile_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" scopingScope Expansion
None.
Validation Failures
None.
CI Failures
None.
Refs #594. Builds on #1401 (independent — no rebase needed; both PRs may merge in any order).