Skip to content

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

Open
lgray wants to merge 6 commits into
phase-rs:mainfrom
lgray:feat/morph-disguise-casting
Open

feat: morph / megamorph / disguise face-down spell casting (CR 708.4)#5171
lgray wants to merge 6 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 0c56525c81f7)

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

Examples: Tin Street Gossip

lgray added 6 commits July 5, 2026 23:09
…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
@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.

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

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants