Skip to content

feat: morph / megamorph / disguise face-down spell casting (CR 708.4)#5171

Merged
matthewevans merged 9 commits into
phase-rs:mainfrom
lgray:feat/morph-disguise-casting
Jul 6, 2026
Merged

feat: morph / megamorph / disguise face-down spell casting (CR 708.4)#5171
matthewevans merged 9 commits into
phase-rs:mainfrom
lgray:feat/morph-disguise-casting

Conversation

@lgray

@lgray lgray commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

🤖 AI text below 🤖

Summary

Adds rules-correct morph / megamorph / disguise face-down spell casting (CR 708.4): a card with Morph, Megamorph, or Disguise can be cast from hand face down as a blank 2/2 creature spell for a fixed {3} (CR 702.37c / 702.168a), placed on the stack, resolving to a face-down permanent. This unlocks the full ~228-card morph/megamorph/disguise class and closes the #5155 D10 deferral — Tin Street Gossip's FaceDownSpell restricted-mana leaf goes live with no type change.

The feature reuses the existing face-down machinery (GameObject.face_down + back_face, apply_face_down_entry_profile, FaceDownProfile::{cloaked_2_2, vanilla_2_2}) rather than inventing new infrastructure:

  • A new AlternativeCastKeyword::FaceDown surfaces the face-down cast as an opt-in alternative to a normal cast (mirrors Evoke/Dash/Prowl), gated on the object carrying an effective Morph/Megamorph/Disguise keyword.
  • CastingVariant::FaceDown tags the resolution context; the cast blanks the object to a 2/2 (ward {2} for Disguise, CR 702.168), stashes the real card in back_face, and pushes it to the stack for {3}.
  • The is_face_down payment seambuild_spell_meta now derives is_face_down = obj.face_down && obj.back_face.is_some(), which is true only for an object continue_cast_face_down blanked at a payment site. This is what makes the OnlyForFaceDownSpell restricted-mana gate (mana.rs) live, closing D10. (Foretell/hideaway set face_down but no back_face; DFC/adventure/transform set back_face but face_down = false — none produce both at a spell-payment site, so the discriminator is exact.)
  • CR 708.9 counter-reveal is free via the existing apply_zone_exit_cleanup — a countered/leaving face-down spell reveals in the graveyard with zero new variant code.

Coverage honesty — closing the loop #5165 opened

PR #5165 ("keep Tin Street coverage honest") classified ManaSpendRestriction::FaceDownSpell as not coverage-supported, with the explicit rationale that SpellMeta.is_face_down is "never true at a payment site... [Tin Street] must stay red until face-down spell casting exists." This PR makes it exist. Now that build_spell_meta sets is_face_down = true at a PaymentContext::Spell site (proven runtime-live by tin_street_gossip_restricted_mana_funds_face_down_cast), leaving the leaf classified dead would be the same coverage dishonesty #5165 fought, only inverted. So this PR completes that contract:

  • is_coverage_supported(FaceDownSpell) flips false → true; the Any([FaceDownSpell, TurnPermanentFaceUp]) disjunction (Tin Street Gossip) becomes coverage-supported. The sole production caller is the parser's Effect::Mana absorption seam (oracle_effect/sequence.rs) — a parse-time coverage classification, not runtime payability (that stays on the separate ManaRestriction::allows_spell), so this is a coverage-honesty change with zero game-behavior change beyond the card becoming supported.
  • Non-vacuity: FaceDownSpell was the last hardcoded-false leaf, so fix(engine): keep Tin Street coverage honest #5165's classifier test could no longer distinguish a dead leaf. It is restructured (not merely flipped) and renamed is_coverage_supported_distinguishes_live_and_dead_leavesis_coverage_supported_all_leaves_supported_empty_any_is_false: every leaf is asserted supported, and Any(vec![]) == false is kept as the remaining non-vacuous false exemplar. Revert-to-red: reverting FaceDownSpell => true flips both its own positive assertion and Tin Street's mixed-Any assertion back to failing. The Tin Street parser test is renamed ..._stays_coverage_red..._is_coverage_supported and now asserts the absorbed restriction with no Effect::Unimplemented residual. The "dead today / red until face-down casting exists" docs in ability.rs / mana.rs / sequence.rs / the restricted-mana test are refreshed.
  • Consequence: Tin Street Gossip's coverage flips RED → GREEN — the D10 payoff, the card is now genuinely playable because face-down casting exists. This is a red→green improvement (informational, not a regression, for the CI --fail-on-engine check); no committed coverage artifact needs refreshing (card-data.json is gitignored; the baseline is republished from main to R2). Tin Street is the one affected card — measured against the full 35,396-card corpus: only Tin Street Gossip and Qarsi Deceiver carry the restricted-mana face-down pattern, and Qarsi stays coverage-red (its restriction also has "turn a manifested creature face up" + "pay a morph cost" leaves that remain Unimplemented independent of the FaceDownSpell classifier), so exactly one card flips.

Frontend is display-only: the existing AlternativeCostModal gains a FaceDown label branch and an alternativeCost.faceDown* i18n key in all 7 locales. The engine owns the {3} cost and the 2/2 body; the client renders engine-provided cost fields and computes nothing.

AI: the face-down cast is already enumerated and scored by the existing candidate pipeline (engine::ai_support::candidate_actions → phase-ai search); no new DeckFeature/policy is needed. A discriminating scoring test proves the AI enumerates both casts and credits the face-down permanent as the 2/2 it actually is.

By-construction AI no-regression (§7)

The new action is provably inert for any deck without Morph/Megamorph/Disguise: (1) the alternative-cast offer is gated on object_has_effective_keyword_kind(Morph|Megamorph|Disguise) — it never fires otherwise; (2) build_spell_meta's is_face_down = face_down && back_face.is_some() is only ever true for an object continue_cast_face_down blanked. So for the cargo ai-gate suite (red/affinity/enchantress mirrors — zero morph cards) the change is a provable no-op. cargo ai-gate was therefore deliberately skipped (a guaranteed 0-flip null that would require a costly card-data regen); the meaningful evidence is the direct discriminating scoring test (crates/phase-ai/tests/morph_face_down_ai_scoring.rs), which proves the AI genuinely enumerates + finite-scores the new action when reachable, and orders a normal 5/5 cast above the 2/2 face-down cast.

Commits

  1. feat(engine): rules-correct morph/disguise face-down spell casting (CR 708.4) — variant + eligibility + {3} cost + blank-and-stack + resolution + the is_face_down seam (D10 closure) + 8 discriminating engine tests.
  2. test(ai): morph face-down cast is enumerated and sanely scored by the AI — the AI scoring test (test-only; the AI already handles the action).
  3. feat(client): surface morph/disguise face-down cast in the alternative-cost modal — FaceDown display copy + faceDown* i18n across 7 locales + folded D10 comment refresh in the restricted-mana test.
  4. refactor(engine): FaceDownSpell mana is coverage-supported now that face-down casting exists — rebase-adaptation onto fix(engine): keep Tin Street coverage honest #5165: flips is_coverage_supported(FaceDownSpell), updates fix(engine): keep Tin Street coverage honest #5165's coverage tests + docs, Tin Street coverage red→green + baseline refresh.

Implementation method (required)

  • Produced via the /engine-implementer pipeline (plan → review-plan → implement → review-impl → commit)
  • Not /engine-implementer — explain why below

Built as team-lead-orchestrated multi-agent work rather than the literal /engine-implementer skill. The engine core was implemented against a plan that passed a review-engine-plan gate (findings folded before code: B1 — the back_face stash belongs to apply_face_down_entry_profile, not apply_face_down_creature_characteristics; B2 — is_face_down threading was deeper than a single site, resolved with the face_down && back_face.is_some() discriminator) and an add-engine-variant gate on CastingVariant::FaceDown / AlternativeCastKeyword::FaceDown (existence / parameterization / categorical-boundary stages). Implementation review was the team lead's independent per-slice checkpoint verification — commit metadata, discriminating-test non-vacuity (incl. the TSG-restricted-mana D10 test's red-on-neutralized-mana property), the is_face_down payment seam vs MTMTE/foretell/manifest, and the coverage-flip A/B/C guards (non-vacuity restructure, verify-the-seam, measured one-card coverage count) — plus a full independent push-gate re-verify (check / clippy --all-targets / test-engine / parser-gate-vs-upstream / measured coverage-diff). It did not use dedicated review-impl subagents, so box 1's literal pipeline is not claimed. Slice E is test-only; Slice F is frontend + i18n; commit 4 is a rebase-adaptation onto freshly-landed #5165.

CR references

  • CR 708.4 — a face-down spell is a 2/2 creature spell with no name, mana cost, or types; may be cast for {3}.
  • CR 708.2 — a face-down permanent/spell is a 2/2 creature with no text, name, subtypes, or mana cost.
  • CR 702.37c / CR 702.37e — Morph: cast face down as a 2/2 for {3}; turn face up any time for its morph cost.
  • CR 702.168a / CR 702.168b — Disguise: cast face down as a 2/2 with ward {2}; turn face up for its disguise cost.
  • CR 708.9 — when a face-down spell/permanent leaves the stack/battlefield, its face is revealed.
  • CR 106.6 — restricted mana spend (Tin Street Gossip OnlyForFaceDownSpell gate, D10 + coverage flip).
  • CR 601.2b — announcing a spell's alternative cost.

Verification

Tilt runs on the primary workdir, not this worktree, so all checks were run directly (-j 4) against the rebased branch (base upstream/main @ 2334f18).

Check Command Result
Workspace compiles (post-rebase) cargo check --workspace ✅ exit 0 — zero exhaustiveness drift from the 8 upstream commits
Lint cargo clippy --workspace --all-targets -D warnings ✅ exit 0, zero warnings (workspace + -p engine --all-targets re-run after the flip)
Engine tests cargo test -p engine ✅ 15355 lib + 1786 integration passed, 0 failed — incl. the flipped/renamed #5165 coverage tests
AI scoring test cargo test -p phase-ai --test morph_face_down_ai_scoring ✅ both tests green (enumeration + 5/5 > 2/2 ordering)
Frontend type + lint pnpm type-check + eslint . ✅ tsc clean (FaceDown assertNever exhaustiveness), eslint 0 errors (flip touches no frontend)
Frontend tests vitest run ✅ 210 files passed, 0 failed (Rust↔TS AlternativeCastKeyword lockstep + 7-locale parity)
Parser gate scripts/check-parser-combinators.sh upstream/main ✅ PASS — the flip's only parser-file touches are a test + a comment (no string-dispatch)
Card-data coverage Tin Street Gossip red→green measured parser-coverage flip: tin_street_gossip_face_down_or_turn_face_up_is_coverage_supported asserts the restriction now absorbs (no Effect::Unimplemented residual). The card database synthesizes engine cards through the same Oracle-text parser (database/synthesis.rs), and the sole FaceDownSpell producer is the narrow all_consuming "cast face-down spell(s)" clause — so Tin Street is the one affected card. A red→green gain is informational (not a regression) for the CI --fail-on-engine check; no committed coverage artifact to refresh (card-data.json is gitignored; the baseline lives on R2). Full-corpus collateral count measured at push-gate against the 35,396-card card-data.json: exactly two cards match the restricted-mana face-down pattern (Tin Street Gossip, Qarsi Deceiver); only Tin Street flips, while Qarsi Deceiver stays coverage-red on its independent "turn a manifested creature face up" + "pay a morph cost" Unimplemented leaves — net exactly 1 card red→green.

Discriminating-test highlights:

  • tin_street_gossip_restricted_mana_funds_face_down_cast — D10 closure: Tin Street's restricted {R}{G} (spendable only for face-down spells) funds a face-down cast end-to-end. Non-vacuous: the mana is restricted so a normal cast cannot be funded by it.
  • is_coverage_supported_distinguishes_live_and_dead_leaves (flipped) — FaceDownSpell and the Tin Street Any disjunction are now coverage-supported.
  • face_down_cast_puts_blank_2_2_on_stack / face_down_cast_resolves_to_face_down_permanent — the stack entry and resolved permanent are the blank 2/2 (CR 708.2).
  • disguise_face_down_has_ward_morph_does_not — Disguise's face-down 2/2 carries ward {2}; Morph's does not (CR 702.168).
  • countered_face_down_spell_is_revealed_in_graveyard — CR 708.9 reveal-on-leaving-stack.
  • morph_creature_offers_face_down_alternative_cast / non_morph_creature_offers_no_face_down_cast — the offer fires iff the object carries the keyword.

🤖 Generated with Claude Code

@lgray lgray requested a review from matthewevans as a code owner July 6, 2026 03:09
@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!

@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 pushing face-down spell casting forward. I found two correctness blockers on this head.

  • Canceling a face-down cast during mana payment can leave the card blanked in its origin zone. continue_cast_face_down applies the face-down profile before prepare_spell_cast_with_variant_override / payment, and only restores the real face if preparation itself fails. If the player reaches WaitingFor::ManaPayment and uses CancelCast, the engine goes through cancel_pending_cast; that path restores alternate spell faces/prototype, but not CastingVariant::FaceDown. The result is that the object can remain face-down/nameless/typeless/no-cost after a canceled cast instead of rolling back to the real card. Please add rollback for face-down casts and a manual-payment cancel regression.

  • Face-down casting is only surfaced from hand. CR 702.37c and CR 702.168b say morph/disguise may be used from any zone from which the card could normally be cast, and CR 708.4 applies the face-down characteristics before stack placement. This PR’s offer/auto-route is gated on obj.zone == Zone::Hand, while the general castability path already recognizes other legal cast zones such as command, graveyard permissions, exile permissions, and top-of-library permissions. A morph card castable from one of those zones should be able to choose the {3} face-down cast too. Please route face-down as a real casting variant over the existing castable-zone machinery instead of a hand-only special case, with targeted tests for at least one non-hand permission path.

Also holding approval until the required parse-diff sticky posts for this parser/engine head.

@github-actions

github-actions Bot commented Jul 6, 2026

Copy link
Copy Markdown

Parse changes introduced by this PR · 1 card(s), 1 signature(s) (baseline: main 6b97e75898d1)

1 card(s) · ability/spend · removed: spend

Examples: Tin Street Gossip

@lgray lgray force-pushed the feat/morph-disguise-casting branch from 49394e9 to 220beee Compare July 6, 2026 04:25
@lgray

lgray commented Jul 6, 2026

Copy link
Copy Markdown
Contributor Author

🤖 AI text below 🤖

Thanks for the review — both blockers addressed. Head is now 220beee9a (rebased onto current main @ 0c56525c8 / #5164; force-pushed). Changes are casting.rs + engine_tests.rs only.

Blocker 1 — canceling a face-down cast leaves the card blanked. Added a CastingVariant::FaceDown branch in handle_cancel_cast that calls the existing single-authority restore_face_down_cast_object (the same restore used on the prep-failure path), beside the Prototype block. Double-fire-safe: FaceDown is absent from restores_front_face_after_stack_exit() and apply_face_down_entry_profile never sets modal_back_face, so the alternative-spell-face restore above does not also fire.
Regression: face_down_cast_cancel_at_mana_payment_restores_real_card — casts a morph creature face down with manual payment so the {3} pauses at WaitingFor::ManaPayment, then CancelCast, then asserts the real name / {5} / Creature / back_face=None / zone=Hand are all restored. Discriminating: with the branch removed the object stays face_down and every restore assertion flips (measured).

Blocker 2 — face-down offered only from hand. The offer/auto-route gate changed from obj.zone == Zone::Hand to prepare_spell_cast(state, player, object_id).is_ok() — the general castable-zone authority (hand / command / graveyard / exile / top-of-library permissions + timing + prohibitions; affordability is still decided by the offer's own normal/face-down checks). The keyword check runs first for short-circuit. Other alt-cost offers (Awaken/Impending/Prototype) stay hand-gated — only morph/disguise carry the CR 702.37c/702.168b cross-zone rule.
Tests: morph_creature_in_graveyard_offers_and_casts_face_down (a GraveyardCastPermission source + a morph creature in the graveyard → offers FaceDown, then casts a blank 2/2 onto the stack, CR 708.2) and reach-guard non_morph_creature_in_graveyard_offers_no_face_down_cast (a vanilla creature under the same permission offers nothing — proves the gate isn't vacuously true). Discriminating: reverting the gate to == Zone::Hand fails the graveyard test at the offer assertion (measured).

Documented, not fixed (separate axis, no printed card currently hits it): prepare_spell_cast().is_ok() evaluates cast prohibitions against the un-blanked object, so a name/MV-conditional prohibition that a face-down spell would legally evade per CR 708.2 (e.g. Meddling Mage naming the card) would over-suppress the offer. Left a strict-failure comment at the gate; it's the same blanked-characteristics-vs-prohibition axis as the existing note in continue_cast_face_down.

Verification on this head (220beee9a):

  • cargo check --workspace → 0 errors
  • cargo test -p engine → 15380 lib + 1786 integration, 0 failed
  • cargo test -p phase-ai --test morph_face_down_ai_scoring → pass
  • cargo clippy -p engine -p phase-ai --all-targets -- -D warnings → 0 warnings

The parse-diff sticky re-runs against the fresh 0c56525c8 baseline (rebasing clears the earlier stale-base leak). Expected surface is unchanged: the single spend → FaceDownSpell flip on Tin Street Gossip.

@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 fixing the two earlier blockers. I found one remaining correctness blocker on this head before I can clear the requested-changes state.

The face-down path is still unreachable for a Morph/Megamorph/Disguise card whose printed mana cost is NoCost. handle_cast_spell_with_payment_mode rejects hand cards with ManaCost::NoCost before the face-down offer block runs, and the offer block also gates on prepare_spell_cast(state, player, object_id).is_ok() against the unblanked face-up object. That means a no-mana-cost morph card cannot reach the {3} face-down alternative-cost path.

Rules evidence: CR 118.6a says an alternative cost may be paid even when the object has an unpayable mana cost, and CR 702.37c says morph pays {3} rather than the mana cost. The fix should let the face-down alternative bypass the face-up no-mana-cost rejection while still requiring an actual Morph/Megamorph/Disguise keyword and legal castable zone. Please add a regression with a ManaCost::NoCost morph/disguise card that can be cast face down for {3}.

@matthewevans matthewevans added the enhancement New feature or request label Jul 6, 2026
@matthewevans

Copy link
Copy Markdown
Member

One more blocker from the same current-head review: the new direct-manifest parser accepts Manifest the top card of your library and attach Lightform to it., but the attach continuation is dropped during lowering/resolution. The parser consumes and attach ... to it in parse_direct_manifest_clause, lowering still emits only Effect::Manifest, and the manifest resolver only manifests the card. The new parser test currently locks in the lossy parse instead of proving the Aura becomes attached.

Please either lower this continuation into a real follow-up attach effect targeting the manifested permanent, or keep this clause unsupported until that model exists. Otherwise the card is reported as implemented while the attach instruction is silently ignored.

@matthewevans

Copy link
Copy Markdown
Member

One more current-head blocker while the face-down routing is being fixed: the auto face-down path can still skip a legal free normal cast.

normal_cast_choice_cost_and_affordability only special-cases unlimited hand free casts before applying printed-cost modifiers. The real preparation path later treats NextSpellModifier::WithoutPayingManaCost as ManaCost::NoCost, but the face-down offer/auto-route check uses the helper result to decide whether the normal cast is affordable. With only {3} available and a pending “cast without paying its mana cost” modifier, the code can see the printed-cost normal path as unaffordable and proceed directly to the face-down {3} cast instead of offering/taking the legal free face-up normal cast.

Please derive the normal option from the same prepared normal-cast cost authority, or explicitly include the next-spell-without-paying path before auto-routing to CastingVariant::FaceDown, with a regression that has only enough mana for {3} but a legal free normal cast available.

@lgray lgray force-pushed the feat/morph-disguise-casting branch from 220beee to d7ce264 Compare July 6, 2026 13:51
@lgray

lgray commented Jul 6, 2026

Copy link
Copy Markdown
Contributor Author

🤖 AI text below 🤖

Thanks — the two in-scope blockers are fixed on the current head (d7ce264a1, rebased onto main @ 407100377). The third is out of scope for this PR; details below.

Blocker — a NoCost morph/megamorph/disguise card couldn't reach the face-down cast — fixed. A card with a printed ManaCost::NoCost is now castable face down for {3}. The Zone::Hand NoCost rejection (CR 118.6) now carries a narrow exception: it stands unless the object has an effective morph/megamorph/disguise keyword and the {3} is affordable (CR 118.6a + CR 702.37c / 702.168b — an alternative cost may be paid against an unpayable mana cost). The candidate/legal-action twin gate (can_cast_prepared_now_with_probe) got the same exception so the AI/frontend surface the option, not just direct dispatch. Soundness guard: normal_cast_choice_cost_and_affordability no longer reports an unpayable NoCost normal cost as affordable, so the card can't fall through to a free face-up cast (CR 118.6). Regressions: nocost_morph_auto_casts_face_down_for_three, nocost_morph_offered_as_legal_action, plus two non-vacuity guards (NoCost non-morph → rejected; NoCost morph without {3} → rejected).

Blocker — auto face-down route could rob a legal free normal cast — fixed. normal_cast_choice_cost_and_affordability now short-circuits on a pending NextSpellModifier::WithoutPayingManaCost (returning NoCost/affordable) via the same authority the real preparation path uses, so an affordable {3} face-down cost can't hide a legal free face-up normal cast. Regression: morph_free_normal_cast_not_robbed_by_face_down_autoroute (printed {5}, only {3} available, pending free-cast modifier → the engine offers the choice rather than auto-routing to face-down).

Third comment (direct-manifest "…and attach Lightform to it" continuation dropped) — out of scope for this PR. That targets parse_direct_manifest_clause, which was introduced by #5164 and is on main, not in this PR's diff — git log 407100377..HEAD -S parse_direct_manifest_clause and -S Lightform are both empty, and this branch touches no parser/manifest code. It's a real issue, but it belongs in its own PR against main rather than the morph face-down PR. Happy to file it as a separate issue so it's tracked — let me know if you'd rather it be bundled here.

Verification on d7ce264a1 (rebased onto current main):

  • cargo check --workspace → 0 errors
  • cargo test -p engine → 15485 lib + 1797 integration, 0 failed
  • cargo test -p phase-ai --test morph_face_down_ai_scoring → pass
  • cargo clippy --workspace --exclude phase-tauri --all-targets --features engine/proptest -- -D warnings → clean

@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 fixing the NoCost morph path and the free-normal-cast auto-route issue. I also rechecked the direct-manifest/Lightform point against this head and agree that is now out of this PR's diff; it should be handled separately against main.

One correctness blocker remains before I can clear the requested-changes state: the face-down castability gate still asks prepare_spell_cast(state, player, object_id).is_ok() on the unblanked face-up object before offering/auto-routing the face-down cast (crates/engine/src/game/casting.rs, around the FaceDown offer block). The in-code strict-failure comment already describes the issue: name- or mana-value-based cast prohibitions are evaluated against the printed card, so the engine can suppress a legal face-down cast.

This is reachable with existing engine machinery, not only hypothetical future text. The runtime already enforces StaticMode::CantBeCast with TargetFilter::HasChosenName; cant_cast_filter_matches compares the chosen name against spell_obj.name. So a Meddling Mage/Nevermore-style permanent naming a morph/disguise card makes prepare_spell_cast() reject the printed face-up object, and the face-down offer never appears. Under CR 708.2 / CR 708.4 the face-down spell has blank characteristics/no name for this check, so that legal {3} face-down cast must still be available even though the normal face-up cast is prohibited.

Please move the face-down offer's legality/prohibition check onto the same blanked face-down characteristics used for the affordability path, or otherwise split the castable-zone/timing permission from characteristics-dependent CantBeCast evaluation so the latter runs against the face-down profile. Add a regression with a chosen-name or mana-value casting prohibition that blocks the normal face-up cast but still offers/casts the Morph/Megamorph/Disguise spell face down for {3}.

lgray added 9 commits July 6, 2026 11:40
…R 708.4)

Cast a Morph/Megamorph/Disguise card from hand face down as a blank 2/2
creature spell for a fixed {3} (CR 702.37c / 702.168a), placed on the stack
(CR 708.4) and resolving to a face-down permanent (CR 702.37c). Closes the
phase-rs#5155 D10 deferral: Tin Street Gossip's FaceDownSpell restricted-mana leaf
now goes live with no type change, unlocking the full 228-card
morph/megamorph/disguise class.

Cast path (opt-in hand alt-cast idiom, mirroring Evoke/Blitz):
- New CastingVariant::FaceDown + AlternativeCastKeyword::FaceDown; the offer
  in handle_cast_spell surfaces AlternativeCastChoice{FaceDown} for any card
  with Morph/Megamorph/Disguise when the fixed {3} is affordable.
- continue_cast_face_down turns the object face down (blank 2/2, real card
  stashed in back_face) via the shared apply_face_down_entry_profile BEFORE
  the stack (CR 708.4). Visibility redaction, resolution to a face-down
  permanent, CR 708.9 counter-reveal, and turn-face-up (CR 702.37e) are all
  inherited from existing machinery.
- The {3} is injected as a synthetic constant into the alt-cost .or() chain,
  never read off the blanked object (whose mana cost is now NoCost).

Payment (closes D10):
- SpellMeta.is_face_down is derived from the blanked object
  (face_down && back_face.is_some()) as a single authority in build_spell_meta,
  with no caller threading. A foretold card (face_down, back_face = None) stays
  face up, preserving the existing foretell guard test. The OnlyForFaceDownSpell
  mana gate now goes live.

Disguise's face-down 2/2 carries ward {2} (CR 702.168a) via the cloaked_2_2
profile; morph/megamorph use vanilla_2_2. The unmorph/turn-up cost is read
downstream from the hidden card's keyword, so CastingVariant::FaceDown is
parameterless.

Tests (engine_tests.rs): 8 discriminating tests including the D10 closure
(Tin Street Gossip's OnlyForAny([FaceDownSpell, TurnFaceUp]) restricted mana
funds the {3}), opponent redaction of the stack spell, disguise ward {2}, and
the CR 708.9 counter-reveal.

Assisted-by: ClaudeCode:claude-opus-4.8
Slice E of the morph/disguise face-down casting PR. Test-only — the AI already
handles the new `AlternativeCastChoice { keyword: FaceDown }` action via the
keyword-agnostic candidate-enumeration arm in `engine::ai_support::candidate_actions`
(candidates.rs), consumed by the phase-ai search through `build_decision_context`.
No production AI code changes; no DeckFeature/policy needed (measure-first showed
the action is already enumerated + scored).

Two discriminating tests:
- (i) both the face-down (Alternative) and printed (Normal) casts are enumerated
  candidates — the face-down cast is not declined by omission.
- (ii) the eval credits the resulting permanent AS a 2/2 (CR 708.2): for a strong
  real 5/5, the normal cast must OUTSCORE the face-down cast. Distinct + ordered
  finite non-sentinel scores prove the eval distinguishes the two boards.

cargo ai-gate is null by construction for this change (the gate suite has no morph
creatures, so no candidate set changes); a direct discriminating scoring test is
the meaningful evidence.

Assisted-by: ClaudeCode:claude-opus-4.8
…e-cost modal

Slice F of the morph/disguise face-down casting PR. The engine already emits
`WaitingFor::AlternativeCastChoice { keyword: FaceDown }` for a Morph/Megamorph/
Disguise card in hand; this adds the display layer only — no game logic, the
engine owns the {3} cost and the blank 2/2 body (CR 708.4 / 702.37c / 702.168a).

- adapter/types.ts: add `{ type: "FaceDown" }` to the AlternativeCastChoice
  keyword union (keeps the boundary-guardrails Rust↔TS lockstep test green).
- AlternativeCostModal.tsx: add the `case "FaceDown"` display-copy arm — mirrors
  the existing per-keyword pattern; the `assertNever` fallback enforces
  exhaustiveness. Costs render from the engine-provided fields.
- i18n: `alternativeCost.faceDown*` keys added to all 7 locales
  (de/en/es/fr/it/pl/pt) — satisfies the resources.test.ts parity gate.

Also folds the now-stale D10 comments in
tests/restricted_mana_face_down_and_face_up.rs: the OnlyForFaceDownSpell gate was
HONEST-DEFERRED (no production path set is_face_down=true); it went LIVE with the
face-down cast (build_spell_meta derives is_face_down = face_down &&
back_face.is_some()). Comment-only — assertions unchanged; end-to-end coverage is
engine_tests::tin_street_gossip_restricted_mana_funds_face_down_cast.

Assisted-by: ClaudeCode:claude-opus-4.8
…-down casting exists

Rebase-adaptation onto phase-rs#5165 ("keep Tin Street coverage honest"), which classified
`ManaSpendRestriction::FaceDownSpell` as not coverage-supported with the explicit
rationale that it "must stay red until face-down spell casting exists." This PR's
engine core makes it exist: `build_spell_meta` now sets `is_face_down = true` at a
`PaymentContext::Spell` site for a morph/megamorph/disguise face-down cast, so the
`FaceDownSpell` leaf is production-live.

- `is_coverage_supported(FaceDownSpell)`: false -> true. The sole production caller
  is the parser's `Effect::Mana` absorption seam (`oracle_effect/sequence.rs`) — a
  parse-time coverage-classification decision, NOT runtime payability (that stays on
  the separate `ManaRestriction::allows_spell`). So this is a coverage-honesty change
  with zero game-behavior change beyond the card becoming supported.
- Tin Street Gossip's `Any([FaceDownSpell, TurnPermanentFaceUp])` now absorbs at the
  seam (coverage red -> green) instead of leaving `Effect::Unimplemented`.
- Non-vacuity: `FaceDownSpell` was the last hardcoded-false leaf. The classifier test
  is restructured (not just flipped) — renamed to
  `is_coverage_supported_all_leaves_supported_empty_any_is_false`; every leaf is now
  asserted supported, with `Any(vec![]) == false` kept as the remaining non-vacuous
  false exemplar. Reverting `FaceDownSpell => true` flips its positive assertion and
  Tin Street's mixed-`Any` assertion back to red.
- `tin_street_gossip_..._stays_coverage_red` -> `..._is_coverage_supported` (asserts
  the absorbed restriction + no `Unimplemented` residual).
- Refreshes the now-stale "dead today / red until face-down casting exists" docs in
  ability.rs, mana.rs, sequence.rs, and the restricted-mana test.

CR 708.4 / CR 702.37c / CR 106.6. Completes the phase-rs#5155 D10 closure at the parser
coverage layer (the engine core closed it at the runtime-gate layer).

Assisted-by: ClaudeCode:claude-opus-4.8
…c/702.168b)

CR 702.37c / CR 702.168b: "You can use a morph/disguise ability to cast a
card from any zone from which you could normally cast it." The {3}
face-down offer was gated on obj.zone == Zone::Hand, so a morph card
castable from the graveyard/exile/command/top-of-library was never
offered the face-down cast. Route the offer through the general
castable-zone authority (prepare_spell_cast(..).is_ok()) instead of the
hand-only special case; the other cast-offer blocks (Awaken/Impending/
Prototype) stay hand-gated because only morph/disguise carry the
cross-zone rule.

Assisted-by: ClaudeCode:claude-opus-4.8
continue_cast_face_down blanks the object to its face-down 2/2 (stashing
the real card in back_face) BEFORE payment, but handle_cancel_cast only
restored alternate spell faces / prototype / prepared-copy — not a
face-down cast. A CancelCast from WaitingFor::ManaPayment (or any pre-
finalize step) therefore left the card blanked, nameless, and no-cost in
its origin zone. Add a CastingVariant::FaceDown branch that routes through
the existing single-authority restore_face_down_cast_object (the same
rollback used on the prep-failure error path), reversing the entire cast
per CR 601.2i.

Assisted-by: ClaudeCode:claude-opus-4.8
…ocker C)

A pending `WithoutPayingManaCost` next-spell modifier (Omniscience-style
one-shot) makes the FREE face-up normal cast of a morph/megamorph/disguise
card legal even when its printed cost is unpayable. `normal_cast_choice_cost_
and_affordability` only recognized the `Unlimited CastFromHandFree`
(Omniscience static) short-circuit, so a pending one-shot modifier was missed:
the normal path reported the unpayable printed cost, `normal_affordable` was
false, and the face-down offer auto-routed to the {3} face-down cast — robbing
the player of the legal free face-up cast.

Mirror the real prep authority (prepare_spell_cast_with_variant_override_inner
via `next_spell_without_paying`, CR 118.9a): when a `WithoutPayingManaCost`
modifier matches the spell, treat the normal path as `ManaCost::NoCost` and
affordable so the engine OFFERS free-normal vs {3}-face-down instead of
auto-routing.

CR 601.2f + CR 118.9a (verified against docs/MagicCompRules.txt).

Test `morph_free_normal_cast_not_robbed_by_face_down_autoroute` is
discriminating: disabling the short-circuit auto-routes face down
(`waiting_for` becomes Priority, not AlternativeCastChoice) → the offer
assertion's `other => panic!` fires (measured).

Assisted-by: ClaudeCode:claude-opus-4.8
…er A)

A card with no mana cost (`ManaCost::NoCost`) that carries an effective
morph/megamorph/disguise keyword is unpayable face up, but IS legally castable
FACE DOWN for the fixed {3} alternative cost (CR 118.6a: an alternative cost
may be applied to an unpayable cost). Three sites wrongly treated such a card
as uncastable:

- The `Zone::Hand` NoCost rejection in `handle_cast_spell_with_payment_mode`
  returned an error before the face-down offer block could run.
- Its candidate/legal-action twin `can_cast_prepared_now_with_probe` returned
  false, so the cast was never surfaced as a legal action even though dispatch
  would have accepted it.
- A soundness trap in `normal_cast_choice_cost_and_affordability`:
  `can_pay_cost_after_auto_tap` returns true for `NoCost` unconditionally, so
  the offer reported the unpayable face-up cast as affordable and offered a
  bogus free face-up cast instead of auto-routing to the {3} face-down. A
  cost reduced to nothing is `{0}` (CR 601.2f), distinct from `NoCost`, so the
  new `!matches!(normal_cost, NoCost)` guard never misfires on a
  cost-reduced-to-zero card.

Both hand-gate exceptions and the legal-action twin are narrowed to the morph
class AND {3} affordability via a shared `object_has_effective_face_down_keyword`
helper (extracted from the inline array at the morph offer block) plus
`can_afford_face_down_cast`, so the unpayable-cost rejection still stands for
every non-morph NoCost card and for a morph without {3}.

CR 118.6 / 118.6a / 601.2f / 702.37c / 702.37b (megamorph) / 702.168b
(disguise), all verified against docs/MagicCompRules.txt.

Tests (all measured discriminating via temporary edit-revert):
- nocost_morph_auto_casts_face_down_for_three: remove E2 → rejected (.expect
  panics); remove the normal_affordable guard → offers not auto-routes (obj
  stays Hand).
- nocost_non_morph_creature_rejected_from_hand / nocost_morph_without_three_
  mana_rejected: keyword + {3}-affordability gating (both stay Err).
- nocost_morph_offered_as_legal_action: remove E4 → can_cast_object_now false.

Assisted-by: ClaudeCode:claude-opus-4.8
…d profile (CR 708.4)

The morph/megamorph/disguise face-down cast castability gate evaluated cast
prohibitions against the un-blanked, printed object. A characteristics-dependent
prohibition (StaticMode::CantBeCast with a name- or mana-value-conditional filter —
e.g. Meddling Mage / Nevermore naming the card) rejected prepare_spell_cast on the
printed face, suppressing the legal {3} face-down cast at both the dispatch offer
gate and the can_cast_object_now feasibility path (dropping it from legal actions
and AI feasibility as well).

Per CR 708.2a a face-down spell is a nameless, mana-cost-less 2/2, and per CR 708.4
prohibitions apply to those face-down characteristics, not the face-up object's;
CR 601.3a lets a player ignore a qualities-conditional prohibition when a proposal
choice (casting face down) changes the qualities it reads.

New face_down_cast_is_permitted helper clones the state, blanks the object via the
existing face_down_cast_profile + apply_face_down_entry_profile infra (mirroring
can_afford_face_down_cast and the real-cast path continue_cast_face_down), then runs
prepare_spell_cast_with_variant_override(Some(FaceDown)).is_ok() against the blanked
profile. Wired into both the dispatch offer gate and the feasibility twin, so the
whole class of characteristics-dependent prohibitions (name, mana value, color,
subtype/type) is covered — blanking zeroes all of them.

Assisted-by: ClaudeCode:claude-opus-4.8
@lgray lgray force-pushed the feat/morph-disguise-casting branch from d7ce264 to 20e13d4 Compare July 6, 2026 16:54
@lgray

lgray commented Jul 6, 2026

Copy link
Copy Markdown
Contributor Author

🤖 AI text below 🤖

Thanks — and good catch. The remaining blocker is fixed on the new head (20e13d444, rebased onto current main @ 6b97e7589).

Face-down cast prohibitions are now evaluated against the blanked face-down profile, not the printed face — fixed. The castability gate previously ran prepare_spell_cast(...).is_ok() on the un-blanked printed object, so a characteristics-conditional CantBeCast prohibition on the printed face suppressed the legal {3} face-down cast. Per CR 708.2a (a face-down spell is a nameless, mana-cost-less 2/2), CR 708.4 (prohibitions apply to the face-down characteristics, not the face-up object's), and CR 601.3a (a player may ignore a qualities-conditional prohibition when a proposal choice — casting face down — changes the qualities it reads), that cast must still be offered.

Fix: a new face_down_cast_is_permitted helper blanks a throwaway clone via the existing face_down_cast_profile + apply_face_down_entry_profile infra (the same blanking the real cast path continue_cast_face_down already does — no second mechanism), then runs prepare_spell_cast_with_variant_override(Some(FaceDown)).is_ok(). Because blanking zeroes name, subtypes, mana value and color, this covers the whole class of characteristics-conditional prohibitions, not just HasChosenName.

I wired it into both call sites, which is where the root cause was broader than the offer path alone: besides the dispatch offer gate, the can_cast_object_now feasibility twin also ran prepare_spell_cast on the printed object with no face-down fallback, so a Meddling-Mage'd morph was silently dropped from legal actions / AI feasibility too. Both now consult the blanked profile (the feasibility twin keeps its keyword + {3}-affordability guards on the un-blanked object, since blanking removes the morph keyword itself).

Regressions (crates/engine/src/game/engine_tests.rs, shared parameterized fixture over {ChosenName, ManaValue} axes):

  • morph_name_prohibition_does_not_suppress_face_down_cast — name axis, dispatch
  • morph_mana_value_prohibition_does_not_suppress_face_down_cast — genuine FilterProp::Cmc >= 5 vs the printed {5}, proving the class (not just HasChosenName)
  • morph_under_name_prohibition_is_castable_now — feasibility twin
  • non_morph_under_name_prohibition_not_castable / morph_without_three_mana_not_castable_under_prohibition — reach-guarded negatives (isolate the prohibition from affordability)
  • morph_casts_face_down_under_multiple_name_prohibitions — hostile multi-authority

Each positive asserts both that the face-up normal cast is genuinely rejected (non-vacuity) and that the {3} face-down cast lands on the stack as a nameless 2/2 (zone == Stack, face_down, power == Some(2), name.is_empty()). Reverting only the dispatch gate reds exactly the three cast tests with "A static ability prevents you from casting this spell" while the feasibility/negative tests stay green — so the gate is load-bearing and the tests discriminate.

Verification on 20e13d444 (rebased onto current main):

  • cargo fmt --all -- --check → clean
  • cargo clippy --workspace --exclude phase-tauri --all-targets --features engine/proptest -- -D warnings → clean
  • cargo test -p engine → 17957 passed / 0 failed (incl. the 6 new regressions)
  • cargo test -p phase-ai --test morph_face_down_ai_scoring → 2 passed / 0 failed

One related gap is deliberately out of scope here and worth its own follow-up: a regular-cost morph whose face-up cast is merely unaffordable (not prohibited) while the {3} face-down cast is affordable — there prepare_spell_cast succeeds on the printed object, so the feasibility else-block is never reached. That's the affordability axis (same family as the earlier NoCost fix), a different root cause from this prohibition blocker. Happy to file it separately alongside the direct-manifest/Lightform item.

@matthewevans matthewevans self-assigned this Jul 6, 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.

Reviewed the current head against the prior blockers and the face-down casting rules boundary. The cancel rollback, non-hand cast zones, NoCost routing, free-normal-cast choice, and blanked-characteristics prohibition path are all covered at the engine level now. Exact-head CI is green, parse-diff is limited to the expected Tin Street Gossip restricted-mana signature change, and there are no unresolved review threads. Approving and enqueueing via the merge queue.

@matthewevans matthewevans added the feature Larger-scoped feature label Jul 6, 2026
@matthewevans matthewevans added this pull request to the merge queue Jul 6, 2026
@matthewevans matthewevans removed their assignment Jul 6, 2026
Merged via the queue into phase-rs:main with commit 4105077 Jul 6, 2026
15 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request feature Larger-scoped feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants