feat: morph / megamorph / disguise face-down spell casting (CR 708.4)#5171
feat: morph / megamorph / disguise face-down spell casting (CR 708.4)#5171lgray wants to merge 6 commits into
Conversation
|
Warning You have reached your daily quota limit. Please wait up to 24 hours and I will start processing your requests again! |
matthewevans
left a comment
There was a problem hiding this comment.
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_downapplies the face-down profile beforeprepare_spell_cast_with_variant_override/ payment, and only restores the real face if preparation itself fails. If the player reachesWaitingFor::ManaPaymentand usesCancelCast, the engine goes throughcancel_pending_cast; that path restores alternate spell faces/prototype, but notCastingVariant::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.
Parse changes introduced by this PR · 1 card(s), 1 signature(s) (baseline: main
|
…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
49394e9 to
220beee
Compare
|
🤖 AI text below 🤖 Thanks for the review — both blockers addressed. Head is now Blocker 1 — canceling a face-down cast leaves the card blanked. Added a Blocker 2 — face-down offered only from hand. The offer/auto-route gate changed from Documented, not fixed (separate axis, no printed card currently hits it): Verification on this head (
The parse-diff sticky re-runs against the fresh |
matthewevans
left a comment
There was a problem hiding this comment.
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}.
|
One more blocker from the same current-head review: the new direct-manifest parser accepts 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. |
|
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.
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 |
🤖 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
FaceDownSpellrestricted-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:AlternativeCastKeyword::FaceDownsurfaces 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::FaceDowntags the resolution context; the cast blanks the object to a 2/2 (ward {2} for Disguise, CR 702.168), stashes the real card inback_face, and pushes it to the stack for {3}.is_face_downpayment seam —build_spell_metanow derivesis_face_down = obj.face_down && obj.back_face.is_some(), which is true only for an objectcontinue_cast_face_downblanked at a payment site. This is what makes theOnlyForFaceDownSpellrestricted-mana gate (mana.rs) live, closing D10. (Foretell/hideaway setface_downbut noback_face; DFC/adventure/transform setback_facebutface_down = false— none produce both at a spell-payment site, so the discriminator is exact.)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::FaceDownSpellas not coverage-supported, with the explicit rationale thatSpellMeta.is_face_downis "never true at a payment site... [Tin Street] must stay red until face-down spell casting exists." This PR makes it exist. Now thatbuild_spell_metasetsis_face_down = trueat aPaymentContext::Spellsite (proven runtime-live bytin_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)flipsfalse → true; theAny([FaceDownSpell, TurnPermanentFaceUp])disjunction (Tin Street Gossip) becomes coverage-supported. The sole production caller is the parser'sEffect::Manaabsorption seam (oracle_effect/sequence.rs) — a parse-time coverage classification, not runtime payability (that stays on the separateManaRestriction::allows_spell), so this is a coverage-honesty change with zero game-behavior change beyond the card becoming supported.FaceDownSpellwas 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 renamedis_coverage_supported_distinguishes_live_and_dead_leaves→is_coverage_supported_all_leaves_supported_empty_any_is_false: every leaf is asserted supported, andAny(vec![]) == falseis kept as the remaining non-vacuous false exemplar. Revert-to-red: revertingFaceDownSpell => trueflips both its own positive assertion and Tin Street's mixed-Anyassertion back to failing. The Tin Street parser test is renamed..._stays_coverage_red→..._is_coverage_supportedand now asserts the absorbed restriction with noEffect::Unimplementedresidual. The "dead today / red until face-down casting exists" docs inability.rs/mana.rs/sequence.rs/ the restricted-mana test are refreshed.--fail-on-enginecheck); no committed coverage artifact needs refreshing (card-data.jsonis gitignored; the baseline is republished frommainto 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 remainUnimplementedindependent of theFaceDownSpellclassifier), so exactly one card flips.Frontend is display-only: the existing
AlternativeCostModalgains aFaceDownlabel branch and analternativeCost.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'sis_face_down = face_down && back_face.is_some()is only ever true for an objectcontinue_cast_face_downblanked. So for thecargo ai-gatesuite (red/affinity/enchantress mirrors — zero morph cards) the change is a provable no-op.cargo ai-gatewas 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
feat(engine): rules-correct morph/disguise face-down spell casting (CR 708.4)— variant + eligibility + {3} cost + blank-and-stack + resolution + theis_face_downseam (D10 closure) + 8 discriminating engine tests.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).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.refactor(engine): FaceDownSpell mana is coverage-supported now that face-down casting exists— rebase-adaptation onto fix(engine): keep Tin Street coverage honest #5165: flipsis_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)
/engine-implementerpipeline (plan → review-plan → implement → review-impl → commit)/engine-implementer— explain why belowCR references
OnlyForFaceDownSpellgate, D10 + coverage flip).Verification
Tilt runs on the primary workdir, not this worktree, so all checks were run directly (
-j 4) against the rebased branch (baseupstream/main@ 2334f18).cargo check --workspacecargo clippy --workspace --all-targets -D warnings-p engine --all-targetsre-run after the flip)cargo test -p enginecargo test -p phase-ai --test morph_face_down_ai_scoringpnpm type-check+eslint .vitest runscripts/check-parser-combinators.sh upstream/maintin_street_gossip_face_down_or_turn_face_up_is_coverage_supportedasserts the restriction now absorbs (noEffect::Unimplementedresidual). The card database synthesizes engine cards through the same Oracle-text parser (database/synthesis.rs), and the soleFaceDownSpellproducer is the narrowall_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-enginecheck; no committed coverage artifact to refresh (card-data.jsonis gitignored; the baseline lives on R2). Full-corpus collateral count measured at push-gate against the 35,396-cardcard-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"Unimplementedleaves — 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) —FaceDownSpelland the Tin StreetAnydisjunction 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