diff --git a/.claude/skills/pr-review-loop/SKILL.md b/.claude/skills/pr-review-loop/SKILL.md index eae3e4adb5..d1d2bb4411 100644 --- a/.claude/skills/pr-review-loop/SKILL.md +++ b/.claude/skills/pr-review-loop/SKILL.md @@ -62,7 +62,11 @@ At sweep start, read `.agents/pr-review/campaign-hotspots.toml` if present (lead **Public-disposition completion gate:** local events and a zero `review` count are necessary but insufficient. For every PR inspected or reviewed this sweep, live-check GitHub before declaring it processed. A substantive blocker must have a current-head formal `CHANGES_REQUESTED` review, unless an existing current-head requested-changes review already states the same unresolved finding. A non-substantive hold must have a current-head maintainer comment explaining the exact external condition and next step. An approval/enqueue must be live-verified as `APPROVED` plus the expected queue/auto-merge state. Do not let a local `blocked`/`held` event substitute for a visible maintainer response. 3. Every packet (and `recommend` output) carries an advisory `contributor` block — standing, scrutiny, `scrutiny_reasons`, `recurrence`, `first_contribution` — derived from the local event log plus `contributor_standing` overrides; it is `null` only when the PR has no author login. Scale review depth by it: `first_contribution` → full evidence bar, and point the author at the `docs/AI-CONTRIBUTOR.md` gates in the first review comment; `elevated` → dig specifically into the recurring signals named in `scrutiny_reasons`; `maintainer_attention` → include the contributor in the sweep report for the maintainer. `light_touch_eligible` permits a lighter pass only while scrutiny is `normal`. -**Model tier is a hard gate; the agentic harness is a scrutiny signal.** `docs/AI-CONTRIBUTOR.md` §0.1.1 accepts **Frontier tier only**. A PR whose canonical `Model:` line names a non-Frontier model (`claude-sonnet-*`, `claude-haiku-*`, `composer-*`, `gpt-5-4` or below, `codex-5-4` or below), or whose commits show one, is closed as out-of-policy without an implementation review. Separately, a `Co-authored-by: Cursor ` trailer is **not** grounds to close on its own — it raises scrutiny to the full evidence bar and forfeits light-touch, because the observed failure mode is CI-as-correction-loop rather than the harness itself. Judge the PR on its merits; good work under Cursor is still good work. +**Model declarations route review depth; they do not authorize an automatic close.** Frontier remains the requested contribution floor in `docs/AI-CONTRIBUTOR.md`, but a non-Frontier declaration or trailer is an elevated-scrutiny signal, never a `decline` by itself. Do not close a PR merely because its stated model falls below that floor. + +For a non-Frontier PR, require the full evidence bar: accurate model declaration, concrete `/engine-implementer` (or a specific `not-applicable`) method record, current-head Gate A and final `review-impl` evidence when applicable, relevant anchors, required verification, and a manual implementation review. A PR with that evidence must be reviewed on its merits. If the evidence is incomplete, request the missing proof or hold it; do not replace the review with a model-policy closure. An explicit maintainer message inviting continued review or asking the contributor to proceed also routes the current head to review, even if older prose describes a model-tier close. + +A `Co-authored-by: Cursor ` trailer remains a scrutiny signal, not a closure reason. The behavioural CI-farming pattern — pushing unverified diagnostic commits or deleting passing assertions to obtain green CI — is review evidence on the current diff, not account-level or batch-close evidence. This maintainer-review rule intentionally overrides older automatic-close wording in `docs/AI-CONTRIBUTOR.md` for this workflow. Check both surfaces, and read what actually matched before acting — a bare `rg cursor` hits `WordCursor` and other identifiers: @@ -253,6 +257,8 @@ When the user explicitly authorizes maintainer actions, the loop may pass clean When delegating labeling, require the handler to classify by the actual diff: ordinary additive engine, parser, or tooling capabilities are **enhancement** by default, even when they touch several files. Reserve **feature** for a genuinely broad mechanic or product change spanning distinct subsystems (for example, a combo workflow that jointly changes engine rules, priority handling, UI, and AI). Never infer `feature` from a `feat:` title, file count, or author identity. +When delegating a PR with a non-Frontier declaration, state explicitly that the declaration is an elevated-scrutiny signal, **not** a decline authorization. The handler must review the current head against the evidence bar above and may only close for a diff-based or independently enforced reason. + Do not perform GitHub mutations from this skill except ordinary review/comment actions explicitly required by the current sweep and policy-configured deferral labels. Approval, queue, update-branch, dequeue, and merge execution still belongs to `pr-contribution-handler`. ## Drift Rule diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 7b886c3283..cfd1758e72 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -12,12 +12,16 @@ ## LLM -Model: +Model: Tier: Thinking: +must report their actual Frontier model, Tier: Frontier, and thinking level. +If your harness does not expose an exact model identifier, report the name it +does give you — e.g. "Model: gpt-5.6-sol (via GitHub Copilot; canonical id not +exposed)" — rather than guessing an identifier. See AI-CONTRIBUTOR.md +§0.1.1. --> ## Implementation method (required) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0108d9c4cc..607d306fb7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -118,13 +118,13 @@ jobs: ./scripts/check-test-card-data-load.sh "$BASE" rust-test: - name: Rust tests (shard ${{ matrix.shard }}/2) + name: Rust tests (shard ${{ matrix.shard }}/4) runs-on: ubuntu-latest - timeout-minutes: 30 + timeout-minutes: 20 strategy: fail-fast: false matrix: - shard: [1, 2] + shard: [1, 2, 3, 4] steps: - uses: actions/checkout@v4 @@ -174,7 +174,7 @@ jobs: # caught before it lands on the branch that feeds staging/preview. env: PROPTEST_CASES: ${{ github.event_name == 'pull_request' && '32' || '256' }} - run: cargo nextest run --profile ci --partition count:${{ matrix.shard }}/2 --workspace --exclude phase-tauri --exclude mtgish-import --features engine/proptest --status-level fail --final-status-level fail + run: cargo nextest run --profile ci --partition count:${{ matrix.shard }}/4 --workspace --exclude phase-tauri --exclude mtgish-import --features engine/proptest --status-level fail --final-status-level fail card-data-gate: name: Card data (generate, validate, coverage) diff --git a/Cargo.lock b/Cargo.lock index 6981294682..5ace0b9d44 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -728,7 +728,7 @@ checksum = "780955b8b195a21ab8e4ac6b60dd1dbdcec1dc6c51c0617964b08c81785e12c9" [[package]] name = "draft-core" -version = "0.49.0" +version = "0.50.0" dependencies = [ "phase-engine", "rand 0.9.4", @@ -740,7 +740,7 @@ dependencies = [ [[package]] name = "draft-wasm" -version = "0.49.0" +version = "0.50.0" dependencies = [ "console_error_panic_hook", "draft-core", @@ -812,7 +812,7 @@ dependencies = [ [[package]] name = "engine-inventory-gen" -version = "0.49.0" +version = "0.50.0" dependencies = [ "anyhow", "proc-macro2", @@ -825,7 +825,7 @@ dependencies = [ [[package]] name = "engine-wasm" -version = "0.49.0" +version = "0.50.0" dependencies = [ "console_error_panic_hook", "getrandom 0.3.4", @@ -877,7 +877,7 @@ checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" [[package]] name = "feed-scraper" -version = "0.49.0" +version = "0.50.0" dependencies = [ "clap", "reqwest", @@ -1683,7 +1683,7 @@ checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" [[package]] name = "lobby-broker" -version = "0.49.0" +version = "0.50.0" dependencies = [ "phase-engine", "serde", @@ -1720,7 +1720,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" [[package]] name = "manabrew-compat" -version = "0.49.0" +version = "0.50.0" dependencies = [ "manabrew-protocol", "phase-engine", @@ -1837,7 +1837,7 @@ dependencies = [ [[package]] name = "mtgish-import" -version = "0.49.0" +version = "0.50.0" dependencies = [ "anyhow", "phase-engine", @@ -2065,7 +2065,7 @@ dependencies = [ [[package]] name = "phase-ai" -version = "0.49.0" +version = "0.50.0" dependencies = [ "flate2", "mimalloc", @@ -2083,7 +2083,7 @@ dependencies = [ [[package]] name = "phase-engine" -version = "0.49.0" +version = "0.50.0" dependencies = [ "assert_matches", "flate2", @@ -2115,7 +2115,7 @@ dependencies = [ [[package]] name = "phase-server" -version = "0.49.0" +version = "0.50.0" dependencies = [ "axum", "clap", @@ -2835,7 +2835,7 @@ dependencies = [ [[package]] name = "seat-reducer" -version = "0.49.0" +version = "0.50.0" dependencies = [ "phase-ai", "phase-engine", @@ -3024,7 +3024,7 @@ dependencies = [ [[package]] name = "server-core" -version = "0.49.0" +version = "0.50.0" dependencies = [ "draft-core", "lobby-broker", diff --git a/Cargo.toml b/Cargo.toml index b7b39b0c5a..5be92803be 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,7 +9,7 @@ members = ["crates/*"] exclude = ["client/src-tauri", "lobby-worker/broker-wasm"] [workspace.package] -version = "0.49.0" +version = "0.50.0" license = "MIT OR Apache-2.0" [workspace.dependencies] diff --git a/client/package.json b/client/package.json index feea715da0..0fcef530eb 100644 --- a/client/package.json +++ b/client/package.json @@ -1,7 +1,7 @@ { "name": "phase-rs-client", "private": true, - "version": "0.49.0", + "version": "0.50.0", "type": "module", "scripts": { "dev": "vite", diff --git a/client/public/changelog-meta.json b/client/public/changelog-meta.json index 3a09029e60..5620a59b26 100644 --- a/client/public/changelog-meta.json +++ b/client/public/changelog-meta.json @@ -1,3 +1,3 @@ { - "latestId": 188 + "latestId": 189 } diff --git a/client/public/changelog.json b/client/public/changelog.json index 174887d065..49dd689147 100644 --- a/client/public/changelog.json +++ b/client/public/changelog.json @@ -1,5 +1,19 @@ { "entries": [ + { + "id": 189, + "date": "2026-08-09", + "title": "Sealed packs and Marvel heroes", + "tags": [ + "new-cards", + "card-fixes", + "gameplay", + "interface", + "ai" + ], + "body": "✨ New Cards & Mechanics\n• Sealed is now a peer of Quick Draft and Cube Draft: open six packs one at a time, see each pack’s pulls, then review a pool grouped by card type before building\n• Captain Marvel, Apex Avenger; Conduit of Worlds; Galactus; and Avenge now have their core card interactions supported\n\n🛠️ Cards That Now Work Right\n• Brainspoil, Hawkeye, Dwarven Armorer, Archnemesis, Batroc the Leaper, Doom’s Time Platform, Dragon Man, Black Bolt, Galvanic Iteration, and Endless Ranks of HYDRA now follow more of their printed targets, costs, counters, or trigger conditions\n• Delayed-trigger payloads retain their following instructions in the correct order, fixing affected multi-clause cards such as Codie and Power Pack\n• Mana abilities that move cards to or from a library are no longer treated as mana abilities\n\n⚔️ Combat & Gameplay\n• Conditional forced-attack requirements such as Galactus’s now parse and enforce the correct defender choices\n• Cleanup discard triggers, player choices, and restored game state retain their correct game context more reliably\n\n🖥️ Interface\n• The game timeline is easier to act on, and remote scry outcomes no longer get lost when several finish together\n\n🤖 AI\n• AI keeps its choices within the actions the engine issued, handles target-dependent optional costs more safely, and recovers gracefully if an AI worker times out", + "discordUrl": "https://discord.com/channels/1485498006781427802/1486432040332296424/1536203698454274138" + }, { "id": 188, "date": "2026-08-08", diff --git a/client/public/feeds/mtggoldfish-commander.json b/client/public/feeds/mtggoldfish-commander.json index 7f35a03475..8f0b217c8c 100644 --- a/client/public/feeds/mtggoldfish-commander.json +++ b/client/public/feeds/mtggoldfish-commander.json @@ -5,7 +5,7 @@ "icon": "G", "format": "commander", "version": 1, - "updated": "2026-08-09T00:00:00Z", + "updated": "2026-08-10T00:00:00Z", "source": "https://www.mtggoldfish.com/metagame/commander", "decks": [ { @@ -19,10 +19,6 @@ "metagame" ], "main": [ - { - "count": 1, - "name": "Trostani, Selesnya's Voice" - }, { "count": 1, "name": "Archangel of Thune" @@ -370,6 +366,10 @@ { "count": 7, "name": "Forest" + }, + { + "count": 1, + "name": "Trostani, Selesnya's Voice" } ], "sideboard": [] @@ -746,12 +746,12 @@ "sideboard": [] }, { - "name": "Y'shtola, Night's Blessed", + "name": "Ashling, the Limitless", "author": "MTGGoldfish", "colors": [ - "U", - "W", - "B" + "B", + "R", + "G" ], "tags": [ "metagame" @@ -759,384 +759,366 @@ "main": [ { "count": 1, - "name": "Y'shtola, Night's Blessed" - }, - { - "count": 1, - "name": "Contaminated Aquifer" + "name": "Ashling, the Limitless" }, { "count": 1, - "name": "Relic of Legends" - }, - { - "count": 3, - "name": "Island" + "name": "Arcane Denial" }, { "count": 1, - "name": "Shineshadow Snarl" + "name": "Arcane Signet" }, { "count": 1, - "name": "Exotic Orchard" + "name": "Ashnod's Altar" }, { "count": 1, - "name": "Toxic Deluge" + "name": "Birds of Paradise" }, { "count": 1, - "name": "Alisaie Leveilleur" + "name": "Blasphemous Act" }, { "count": 1, - "name": "Prairie Stream" + "name": "Blood Crypt" }, { "count": 1, - "name": "Thought Vessel" + "name": "Brushland" }, { "count": 1, - "name": "Vindicate" + "name": "Burnt Offering" }, { "count": 1, - "name": "Talisman of Dominance" + "name": "Catalyst Elemental" }, { "count": 1, - "name": "Final Judgment" + "name": "Cavalier of Flame" }, { "count": 1, - "name": "Relm's Sketching" + "name": "Cavalier of Gales" }, { "count": 1, - "name": "Summon: Primal Odin" + "name": "Cavalier of Night" }, { "count": 1, - "name": "Fetid Heath" + "name": "Cavalier of Thorns" }, { "count": 1, - "name": "Evolving Wilds" + "name": "Chain Reaction" }, { "count": 1, - "name": "Talisman of Progress" + "name": "Chromatic Lantern" }, { "count": 1, - "name": "Combat Tutorial" + "name": "Cinder Glade" }, { "count": 1, - "name": "Crux of Fate" + "name": "Command Tower" }, { "count": 1, - "name": "Archmage Emeritus" + "name": "Consign to Memory" }, { "count": 1, - "name": "Murderous Rider" + "name": "Culling the Weak" }, { "count": 1, - "name": "Eden, Seat of the Sanctum" + "name": "Cultivate" }, { "count": 1, - "name": "Coveted Jewel" + "name": "Dark Ritual" }, { "count": 1, - "name": "Reaper's Scythe" + "name": "Eirdu, Carrier of Dawn" }, { "count": 1, - "name": "Sunlit Marsh" + "name": "Elvish Spirit Guide" }, { "count": 1, - "name": "Exsanguinate" + "name": "Endurance" }, { "count": 1, - "name": "Hildibrand Manderville" - }, - { - "count": 4, - "name": "Plains" + "name": "Ephemerate" }, { "count": 1, - "name": "Eject" + "name": "Evolving Wilds" }, { "count": 1, - "name": "Void Rend" + "name": "Exotic Orchard" }, { "count": 1, - "name": "Sol Ring" + "name": "Explore" }, { "count": 1, - "name": "Talisman of Hierarchy" + "name": "Farseek" }, { "count": 1, - "name": "Ice Magic" + "name": "Feign Death" }, { "count": 1, - "name": "Syphon Mind" + "name": "Fellwar Stone" }, { "count": 1, - "name": "Krile Baldesion" + "name": "Flamebraider" }, { "count": 1, - "name": "Sublime Epiphany" + "name": "Flamekin Harbinger" }, { - "count": 1, - "name": "Esper Sentinel" + "count": 5, + "name": "Forest" }, { "count": 1, - "name": "Observed Stasis" + "name": "Frontier Bivouac" }, { "count": 1, - "name": "Stroke of Midnight" + "name": "Fury" }, { "count": 1, - "name": "Magic Damper" + "name": "Garruk's Uprising" }, { "count": 1, - "name": "Lyse Hext" + "name": "Generous Ent" }, { "count": 1, - "name": "Plea for Power" + "name": "Greenwarden of Murasa" }, { "count": 1, - "name": "Mana Drain" + "name": "Grief" }, { "count": 1, - "name": "Dancer's Chakrams" + "name": "Heartless Summoning" }, { "count": 1, - "name": "Desolate Mire" + "name": "Ignoble Hierarch" }, { "count": 1, - "name": "The One Ring" + "name": "Jeska's Will" }, { "count": 1, - "name": "Hermes, Overseer of Elpis" + "name": "Jungle Shrine" }, { "count": 1, - "name": "Lethal Scheme" + "name": "Karmic Guide" }, { "count": 1, - "name": "Arcane Signet" + "name": "Karplusan Forest" }, { "count": 1, - "name": "Port Town" + "name": "Llanowar Wastes" }, { "count": 1, - "name": "Hraesvelgr of the First Brood" + "name": "Lorien Revealed" }, { "count": 1, - "name": "Isolated Chapel" + "name": "Maelstrom Wanderer" }, { "count": 1, - "name": "Tome of Legends" + "name": "Mass of Mysteries" }, { - "count": 1, - "name": "Cut a Deal" + "count": 7, + "name": "Mountain" }, { "count": 1, - "name": "Emet-Selch of the Third Seat" + "name": "Mulldrifter" }, { "count": 1, - "name": "Underground River" + "name": "Nature's Lore" }, { "count": 1, - "name": "Sharlayan, Nation of Scholars" - }, - { - "count": 4, - "name": "Swamp" + "name": "Negate" }, { "count": 1, - "name": "Estinien Varlineau" + "name": "Noble Hierarch" }, { "count": 1, - "name": "Rhystic Study" + "name": "Noxious Revival" }, { "count": 1, - "name": "Idyllic Beachfront" + "name": "Overgrown Tomb" }, { "count": 1, - "name": "Drowned Catacomb" + "name": "Path of Ancestry" }, { "count": 1, - "name": "Demolition Field" + "name": "Path to Exile" }, { "count": 1, - "name": "Dig Through Time" + "name": "Pyre of Heroes" }, { "count": 1, - "name": "Fandaniel, Telophoroi Ascian" + "name": "Rampant Growth" }, { "count": 1, - "name": "White Auracite" + "name": "Reveillark" }, { "count": 1, - "name": "Summon: Good King Mog XII" + "name": "Risen Reef" }, { "count": 1, - "name": "Temple of the False God" + "name": "Sacrifice" }, { "count": 1, - "name": "Command Tower" + "name": "Savage Lands" }, { "count": 1, - "name": "Scavenger Grounds" + "name": "Secluded Courtyard" }, { "count": 1, - "name": "Sunken Ruins" + "name": "Semblance Anvil" }, { "count": 1, - "name": "Darkwater Catacombs" + "name": "Shivan Reef" }, { "count": 1, - "name": "Sunken Hollow" + "name": "Simian Spirit Guide" }, { "count": 1, - "name": "Propaganda" + "name": "Smokebraider" }, { "count": 1, - "name": "Cyclonic Rift" + "name": "Sol Ring" }, { "count": 1, - "name": "Ash Barrens" + "name": "Stomping Ground" }, { "count": 1, - "name": "Circle of Power" + "name": "Sulfurous Springs" }, { "count": 1, - "name": "Venat, Heart of Hydaelyn" + "name": "Sunderflock" }, { - "count": 1, - "name": "Instant Ramen" + "count": 3, + "name": "Swamp" }, { "count": 1, - "name": "Rite of Replication" + "name": "Swords to Plowshares" }, { "count": 1, - "name": "Alphinaud Leveilleur" + "name": "Talisman of Creativity" }, { "count": 1, - "name": "Thancred Waters" + "name": "Talisman of Impulse" }, { "count": 1, - "name": "Mystic Remora" + "name": "Talisman of Resilience" }, { "count": 1, - "name": "Baleful Strix" + "name": "Terramorphic Expanse" }, { "count": 1, - "name": "Dreams of Laguna" + "name": "Three Visits" }, { "count": 1, - "name": "Skycloud Expanse" + "name": "Unclaimed Territory" }, { "count": 1, - "name": "Archaeomancer's Map" + "name": "Undying Malice" }, { "count": 1, - "name": "Cleansing Nova" + "name": "Up the Beanstalk" }, { "count": 1, - "name": "Arcane Sanctum" + "name": "Utopia Sprawl" }, { "count": 1, - "name": "Tataru Taru" + "name": "Village Rites" }, { "count": 1, - "name": "Choked Estuary" + "name": "Yavimaya Coast" } ], "sideboard": [] }, { - "name": "Ashling, the Limitless", + "name": "Y'shtola, Night's Blessed", "author": "MTGGoldfish", "colors": [ - "W", "U", - "B", - "R", - "G" + "W", + "B" ], "tags": [ "metagame" @@ -1144,666 +1126,675 @@ "main": [ { "count": 1, - "name": "Mass of Mysteries" - }, - { - "count": 1, - "name": "Elemental Spectacle" - }, - { - "count": 1, - "name": "Springleaf Parade" + "name": "Adarkar Wastes" }, { "count": 1, - "name": "Jubilation" + "name": "Alisaie Leveilleur" }, { "count": 1, - "name": "Impulsivity" + "name": "Alphinaud Leveilleur" }, { "count": 1, - "name": "Lamentation" + "name": "Arcane Sanctum" }, { "count": 1, - "name": "Belonging" + "name": "Arcane Signet" }, { "count": 1, - "name": "Subterfuge" + "name": "Archaeomancer's Map" }, { "count": 1, - "name": "Rain-Slicked Copse" + "name": "Azorius Chancery" }, { "count": 1, - "name": "Sodden Verdure" + "name": "Azorius Signet" }, { "count": 1, - "name": "Abundant Countryside" + "name": "Bender's Waterskin" }, { "count": 1, - "name": "Endurance" + "name": "Breena, the Demagogue" }, { "count": 1, - "name": "Fury" + "name": "Caves of Koilos" }, { "count": 1, - "name": "Haunting Voyage" + "name": "Cloud Key" }, { "count": 1, - "name": "Avenger of Zendikar" + "name": "Coercive Impetus" }, { "count": 1, - "name": "Cavalier of Thorns" + "name": "Command Tower" }, { "count": 1, - "name": "Greenwarden of Murasa" + "name": "Contaminated Landscape" }, { "count": 1, - "name": "Selvala, Heart of the Wilds" + "name": "Copy Enchantment" }, { "count": 1, - "name": "Titan of Industry" + "name": "Curiosity" }, { "count": 1, - "name": "Muldrotha, the Gravetide" + "name": "Dimir Aqueduct" }, { "count": 1, - "name": "Omnath, Locus of Rage" + "name": "Dimir Signet" }, { "count": 1, - "name": "Yarok, the Desecrated" + "name": "Duelist's Heritage" }, { "count": 1, - "name": "Omnath, Locus of the Roil" + "name": "Endless Foot Assault" }, { "count": 1, - "name": "Timeless Lotus" + "name": "Eriette of the Charmed Apple" }, { "count": 1, - "name": "Shatter the Sky" + "name": "Estrid's Invocation" }, { "count": 1, - "name": "Hoofprints of the Stag" + "name": "Exotic Orchard" }, { "count": 1, - "name": "Slithermuse" + "name": "Eye of Nidhogg" }, { "count": 1, - "name": "Descendants' Fury" + "name": "Fealty to the Realm" }, { "count": 1, - "name": "Blasphemous Act" + "name": "Fear of Sleep Paralysis" }, { "count": 1, - "name": "Cream of the Crop" + "name": "Flare of Fortitude" }, { "count": 1, - "name": "Kindred Summons" + "name": "Frantic Search" }, { "count": 1, - "name": "Bane of Progress" + "name": "Ghostly Prison" }, { "count": 1, - "name": "Realmwalker" + "name": "Ghoulish Impetus" }, { "count": 1, - "name": "Return of the Wildspeaker" + "name": "Glacial Fortress" }, { "count": 1, - "name": "Faeburrow Elder" + "name": "Herald of Amity" }, { "count": 1, - "name": "Vernal Sovereign" + "name": "Horizon of Progress" }, { "count": 1, - "name": "Horde of Notions" + "name": "Inkshield" }, { "count": 1, - "name": "Maelstrom Wanderer" + "name": "Inquisitive Glimmer" }, { "count": 1, - "name": "Jegantha, the Wellspring" + "name": "Ishgard, the Holy See" }, { - "count": 1, - "name": "Chromatic Lantern" + "count": 8, + "name": "Island" }, { "count": 1, - "name": "Primal Beyond" + "name": "Killian, Decisive Mentor" }, { "count": 1, - "name": "Raging Ravine" + "name": "Killian, Ink Duelist" }, { "count": 1, - "name": "Exotic Orchard" + "name": "Kor Spiritdancer" }, { "count": 1, - "name": "Flamekin Village" + "name": "Martial Impetus" }, { "count": 1, - "name": "Path to Exile" + "name": "Mesa Enchantress" }, { "count": 1, - "name": "Mulldrifter" + "name": "Mirrormade" }, { "count": 1, - "name": "Reality Shift" + "name": "Orzhov Basilica" }, { "count": 1, - "name": "Shriekmaw" + "name": "Orzhov Signet" }, { "count": 1, - "name": "Shimmercreep" + "name": "Parasitic Impetus" }, { "count": 1, - "name": "Flamebraider" + "name": "Path of Ancestry" }, { - "count": 1, - "name": "Crib Swap" + "count": 9, + "name": "Plains" }, { "count": 1, - "name": "Secluded Courtyard" + "name": "Prairie Stream" }, { "count": 1, - "name": "Sol Ring" + "name": "Promise of Loyalty" }, { "count": 1, - "name": "Command Tower" + "name": "Propaganda" }, { "count": 1, - "name": "Arcane Signet" + "name": "Psychic Impetus" }, { "count": 1, - "name": "Distant Melody" + "name": "Public Enemy" }, { "count": 1, - "name": "Incandescent Soulstoke" + "name": "Reaper's Scythe" }, { "count": 1, - "name": "Eclipsed Flamekin" + "name": "Redemption Arc" }, { "count": 1, - "name": "Foundation Breaker" + "name": "Restoration Magic" }, { "count": 1, - "name": "Garruk's Uprising" + "name": "Rewind" }, { "count": 1, - "name": "Risen Reef" + "name": "Saw It Coming" }, { "count": 1, - "name": "Fellwar Stone" + "name": "Scriv, the Obligator" }, { "count": 1, - "name": "Unclaimed Territory" + "name": "Shardmage's Rescue" }, { "count": 1, - "name": "Ancient Ziggurat" + "name": "Shark Typhoon" }, { "count": 1, - "name": "Frontier Bivouac" + "name": "Sheltered by Ghosts" }, { "count": 1, - "name": "Sandsteppe Citadel" + "name": "Skrelv, Defector Mite" }, { "count": 1, - "name": "Savage Lands" + "name": "Spell Swindle" }, { "count": 1, - "name": "Opulent Palace" + "name": "Split Up" }, { "count": 1, - "name": "Seaside Citadel" + "name": "Starfield Mystic" }, { "count": 1, - "name": "Jungle Shrine" + "name": "Stock Up" }, { "count": 1, - "name": "Smokebraider" + "name": "Sublime Epiphany" }, { "count": 1, - "name": "Ingot Chewer" + "name": "Sunken Hollow" }, { - "count": 1, - "name": "Fertile Ground" + "count": 4, + "name": "Swamp" }, { "count": 1, - "name": "Abundant Growth" + "name": "Swords to Plowshares" }, { "count": 1, - "name": "Kodama's Reach" + "name": "Sygg, River Cutthroat" }, { "count": 1, - "name": "Cultivate" + "name": "Tenuous Truce" }, { "count": 1, - "name": "Path of Ancestry" + "name": "Terrain Generator" }, { "count": 1, - "name": "Thriving Grove" + "name": "The Death of Gwen Stacy" }, { "count": 1, - "name": "Thriving Heath" + "name": "Underground River" }, { "count": 1, - "name": "Thriving Isle" + "name": "Unwind" }, { "count": 1, - "name": "Opal Palace" + "name": "Void Rend" }, { "count": 1, - "name": "Thriving Bluff" + "name": "Vow of Malice" }, { "count": 1, - "name": "Thriving Moor" - }, - { - "count": 2, - "name": "Plains" - }, - { - "count": 2, - "name": "Island" - }, - { - "count": 2, - "name": "Swamp" - }, - { - "count": 2, - "name": "Mountain" - }, - { - "count": 8, - "name": "Forest" + "name": "Y'shtola, Night's Blessed" }, { "count": 1, - "name": "Ashling, the Limitless" + "name": "Yahenni's Expertise" } ], "sideboard": [] }, { - "name": "Giada, Font of Hope", + "name": "Lathril, Blade of the Elves", "author": "MTGGoldfish", - "colors": [ - "W" - ], + "colors": [], "tags": [ "metagame" ], "main": [ { "count": 1, - "name": "Giada, Font of Hope" + "name": "Birchlore Rangers [ONS]" }, { "count": 1, - "name": "Lyra Dawnbringer" + "name": "Essence Warden [PLC]" }, { "count": 1, - "name": "Righteous Valkyrie" + "name": "Llanowar Elves [M11]" }, { "count": 1, - "name": "Youthful Valkyrie" + "name": "Virulent Emissary [ECL]" }, { "count": 1, - "name": "Sephara, Sky's Blade" + "name": "Dwynen's Elite [ORI]" }, { "count": 1, - "name": "Herald of War" + "name": "Elvish Visionary [ALA]" }, { "count": 1, - "name": "Angel of Vitality" + "name": "Elvish Warmaster [KHM]" }, { "count": 1, - "name": "Valkyrie Harbinger" + "name": "Llanowar Loamspeaker [DMU] (F)" }, { "count": 1, - "name": "Inspiring Overseer" + "name": "Miara, Thorn of the Glade [CMR]" }, { "count": 1, - "name": "Exemplar of Light" + "name": "Quirion Explorer [PS]" }, { "count": 1, - "name": "Emeria Shepherd" + "name": "Thornweald Archer [FUT]" }, { "count": 1, - "name": "Bishop of Wings" + "name": "Wellwisher [ONS]" }, { "count": 1, - "name": "Angel of Finality" + "name": "Wren's Run Vanquisher [EVG]" }, { "count": 1, - "name": "Angelic Field Marshal" + "name": "Glissa Sunslayer [ECC]" }, { "count": 1, - "name": "Firemane Commando" + "name": "Harald, King of Skemfar [KHM]" }, { "count": 1, - "name": "Thraben Watcher" + "name": "Honest Rutstein [OTJ]" }, { "count": 1, - "name": "Segovian Angel" + "name": "Imperious Perfect [CMR]" }, { "count": 1, - "name": "Sunblast Angel" + "name": "Jagged-Scar Archers [LRW]" }, { "count": 1, - "name": "Dazzling Angel" + "name": "Lys Alana Scarblade [LRW]" }, { "count": 1, - "name": "Angel of the Ruins" + "name": "Morcant's Loyalist [ECL]" }, { "count": 1, - "name": "Metropolis Reformer" + "name": "Ochran Assassin [GRN]" }, { "count": 1, - "name": "Baneslayer Angel" + "name": "Reclamation Sage [PRM-GDP]" }, { "count": 1, - "name": "Angelic Skirmisher" + "name": "Steel Leaf Champion [DOM]" }, { "count": 1, - "name": "Starfield Shepherd" + "name": "Wood Elves [EX]" }, { "count": 1, - "name": "Herald of Eternal Dawn" + "name": "Creakwood Liege <019d4a1a-7260-75df-8d47-de58ca5eff30> [SOC]" }, { "count": 1, - "name": "Shattered Angel" + "name": "Dwynen, Gilt-Leaf Daen [ORI]" }, { "count": 1, - "name": "Serra Avenger" + "name": "Elvish Regrower [FDN]" }, { "count": 1, - "name": "Herald of the Host" + "name": "High Perfect Morcant [ECL]" }, { "count": 1, - "name": "Angel of the Dire Hour" + "name": "Lys Alana Huntmaster [LRW]" }, { "count": 1, - "name": "Serra Angel" + "name": "Moon-Vigil Adherents [ECL]" }, { "count": 1, - "name": "Angelic Curator" + "name": "Nullmage Shepherd [RAV]" }, { "count": 1, - "name": "Sol Ring" + "name": "Oviya, Automech Artisan [DFT]" }, { "count": 1, - "name": "Arcane Signet" + "name": "Poison-Tip Archer [BLC]" }, { "count": 1, - "name": "Patchwork Banner" + "name": "Storrev, Devkarin Lich [WAR]" }, { "count": 1, - "name": "Heraldic Banner" + "name": "Surrak, the Hunt Caller [FDN]" }, { "count": 1, - "name": "Pristine Talisman" + "name": "Wirewood Channeler [LGN]" }, { "count": 1, - "name": "Pearl Medallion" + "name": "Wren's Run Packmaster [LRW]" }, { "count": 1, - "name": "Marble Diamond" + "name": "Elvish Dreadlord [CMR]" }, { "count": 1, - "name": "Swiftfoot Boots" + "name": "Mirkwood Elk [LTC]" }, { "count": 1, - "name": "Moonsilver Spear" + "name": "Garruk, Cursed Huntsman [ELD] (F)" }, { "count": 1, - "name": "Cosmos Elixir" + "name": "Nissa Revane [ZEN]" }, { "count": 1, - "name": "Gathering Stone" + "name": "Bounty of Skemfar [J25]" }, { "count": 1, - "name": "Oketra's Monument" + "name": "Culling Ritual [ONC]" }, { "count": 1, - "name": "Generous Gift" + "name": "Vicious Rivalry <019d72c4-d8f7-75bf-afd7-e94991f3c1ac> [SOS]" }, { "count": 1, - "name": "Stroke of Midnight" + "name": "Ezuri's Predation [C21]" }, { "count": 1, - "name": "Swords to Plowshares" + "name": "Assassin's Trophy [ACR]" }, { "count": 1, - "name": "Path to Exile" + "name": "Tear Asunder [EOC]" }, { "count": 1, - "name": "Brave the Elements" + "name": "Beast Within [MIC]" }, { "count": 1, - "name": "Unbreakable Formation" + "name": "Dina's Guidance <019d72d5-17ae-7f45-b5c9-f28a6ca4d73c> [SOS]" }, { "count": 1, - "name": "Disenchant" + "name": "Eyeblight's Ending [EMA]" }, { "count": 1, - "name": "Make a Stand" + "name": "Hunter's Insight [M12]" }, { "count": 1, - "name": "Dawn Charm" + "name": "Avengers Monitoring Station <019edceb-7f53-7114-9bfe-83d2016c480b> [MSC]" }, { "count": 1, - "name": "Mana Tithe" + "name": "Sol Ring [C20]" }, { "count": 1, - "name": "Austere Command" + "name": "Arcane Signet [C21]" }, { "count": 1, - "name": "Fumigate" + "name": "Cauldron of Essence <019d71d5-8971-7548-a600-77e645309fd1> [SOS]" }, { "count": 1, - "name": "Vanquish the Horde" + "name": "Lifecrafter's Bestiary [AER]" }, { "count": 1, - "name": "Mass Calcify" + "name": "Summoning Materia [FIC]" }, { "count": 1, - "name": "Armored Ascension" + "name": "Colossal Majesty [AFC]" }, { "count": 1, - "name": "Folk Hero" + "name": "Elvish Guidance [ONS]" }, { "count": 1, - "name": "Authority of the Consuls" + "name": "Garruk's Uprising [FDN] (F)" }, { "count": 1, - "name": "True Conviction" + "name": "Deathreap Ritual [NCC]" }, { "count": 1, - "name": "Cathars' Crusade" + "name": "Greater Good [BBD]" }, { "count": 1, - "name": "Monologue Tax" + "name": "Glorious Sunrise [VOW]" }, { "count": 1, - "name": "Leyline of Hope" + "name": "Moldervine Reclamation [M20]" + }, + { + "count": 12, + "name": "Forest <295> [SOI]" + }, + { + "count": 9, + "name": "Swamp <263> [BFZ]" }, { "count": 1, - "name": "Adventurer's Inn" + "name": "High Market [FIC]" }, { "count": 1, - "name": "Path of Ancestry" + "name": "Path of Ancestry [CLB]" }, { "count": 1, - "name": "Radiant Fountain" + "name": "Command Tower [C16]" }, { "count": 1, - "name": "Rogue's Passage" + "name": "Llanowar Wastes [EOC]" }, { "count": 1, - "name": "Secluded Steppe" + "name": "Woodland Cemetery [DOM]" }, { "count": 1, - "name": "Seraph Sanctuary" + "name": "Vernal Fen [EOC]" }, { - "count": 30, - "name": "Plains" + "count": 1, + "name": "Necroblossom Snarl <019d4a1a-c1e7-73a2-a4bb-9ec4dbf7a339> [SOC]" + }, + { + "count": 1, + "name": "Lathril, Blade of the Elves [FDN]" + }, + { + "count": 1, + "name": "Ash Barrens [LTC]" + }, + { + "count": 1, + "name": "Blooming Marsh [KLD]" + }, + { + "count": 1, + "name": "Castle Garenbrig [ELD]" + }, + { + "count": 1, + "name": "Pendelhaven [A25]" + }, + { + "count": 1, + "name": "Spymaster's Vault [MH3]" + }, + { + "count": 1, + "name": "Titan's Grave <019d4a3e-d6ab-7d86-be06-bb27ada89890> [SOS]" + }, + { + "count": 1, + "name": "Viridescent Bog [BLC]" } ], "sideboard": [] }, { - "name": "Meren of Clan Nel Toth", + "name": "Giada, Font of Hope", "author": "MTGGoldfish", "colors": [ - "G", - "B" + "W" ], "tags": [ "metagame" @@ -1811,961 +1802,989 @@ "main": [ { "count": 1, - "name": "Meren of Clan Nel Toth" + "name": "Giada, Font of Hope" }, { "count": 1, - "name": "Protean Hulk" + "name": "Lyra Dawnbringer" }, { "count": 1, - "name": "Gray Merchant of Asphodel" + "name": "Righteous Valkyrie" }, { "count": 1, - "name": "Defiling Daemogoth" + "name": "Youthful Valkyrie" }, { "count": 1, - "name": "Eccentric Pestfinder" + "name": "Sephara, Sky's Blade" }, { "count": 1, - "name": "Blight Mound" + "name": "Herald of War" }, { "count": 1, - "name": "Pest Rescuer" + "name": "Angel of Vitality" }, { "count": 1, - "name": "Ribtruss Roaster" + "name": "Valkyrie Harbinger" }, { "count": 1, - "name": "Feral Appetite" + "name": "Inspiring Overseer" }, { "count": 1, - "name": "Trudge Garden" + "name": "Exemplar of Light" }, { "count": 1, - "name": "Beledros Witherbloom" + "name": "Emeria Shepherd" }, { "count": 1, - "name": "Grave Researcher" + "name": "Bishop of Wings" }, { "count": 1, - "name": "Witch of the Moors" + "name": "Angel of Finality" }, { "count": 1, - "name": "Veinwitch Coven" + "name": "Angelic Field Marshal" }, { "count": 1, - "name": "Deadly Brew" + "name": "Firemane Commando" }, { "count": 1, - "name": "Old Stickfingers" + "name": "Thraben Watcher" }, { "count": 1, - "name": "Satyr Wayfinder" + "name": "Segovian Angel" }, { "count": 1, - "name": "Zulaport Cutthroat" + "name": "Sunblast Angel" }, { "count": 1, - "name": "Blood Artist" + "name": "Dazzling Angel" }, { "count": 1, - "name": "Blossoming Bogbeast" + "name": "Angel of the Ruins" }, { "count": 1, - "name": "Spore Frog" + "name": "Metropolis Reformer" }, { "count": 1, - "name": "Assassin's Trophy" + "name": "Baneslayer Angel" }, { "count": 1, - "name": "Foundation Breaker" + "name": "Angelic Skirmisher" }, { "count": 1, - "name": "Haywire Mite" + "name": "Starfield Shepherd" }, { "count": 1, - "name": "Infernal Grasp" + "name": "Herald of Eternal Dawn" }, { "count": 1, - "name": "Shriekmaw" + "name": "Shattered Angel" }, { "count": 1, - "name": "Reclamation Sage" + "name": "Serra Avenger" }, { "count": 1, - "name": "Plagued Rusalka" + "name": "Herald of the Host" }, { "count": 1, - "name": "Brood Butcher" + "name": "Angel of the Dire Hour" }, { "count": 1, - "name": "Mortality Spear" + "name": "Serra Angel" }, { "count": 1, - "name": "Ravenous Chupacabra" + "name": "Angelic Curator" }, { "count": 1, - "name": "Immoral Bargain" + "name": "Sol Ring" }, { "count": 1, - "name": "Final Act" + "name": "Arcane Signet" }, { "count": 1, - "name": "Toxic Deluge" + "name": "Patchwork Banner" }, { "count": 1, - "name": "Pest Infestation" + "name": "Heraldic Banner" }, { "count": 1, - "name": "Massacre Girl" + "name": "Pristine Talisman" }, { "count": 1, - "name": "Vampiric Rites" + "name": "Pearl Medallion" }, { "count": 1, - "name": "Morbid Opportunist" + "name": "Marble Diamond" }, { "count": 1, - "name": "Smothering Abomination" + "name": "Swiftfoot Boots" }, { "count": 1, - "name": "Umbral Collar Zealot" + "name": "Moonsilver Spear" }, { "count": 1, - "name": "Viscera Seer" + "name": "Cosmos Elixir" }, { "count": 1, - "name": "Woe Strider" + "name": "Gathering Stone" }, { "count": 1, - "name": "Grim Haruspex" + "name": "Oketra's Monument" }, { "count": 1, - "name": "Midnight Reaper" + "name": "Generous Gift" }, { "count": 1, - "name": "Deathreap Ritual" + "name": "Stroke of Midnight" }, { "count": 1, - "name": "Moldervine Reclamation" + "name": "Swords to Plowshares" }, { "count": 1, - "name": "Unmarked Grave" + "name": "Path to Exile" }, { "count": 1, - "name": "Buried Alive" + "name": "Brave the Elements" }, { "count": 1, - "name": "Jarad's Orders" + "name": "Unbreakable Formation" }, { "count": 1, - "name": "Fauna Shaman" + "name": "Disenchant" }, { "count": 1, - "name": "Vile Entomber" + "name": "Make a Stand" }, { "count": 1, - "name": "Birthing Pod" + "name": "Dawn Charm" }, { "count": 1, - "name": "Grisly Salvage" + "name": "Mana Tithe" }, { "count": 1, - "name": "Arcane Signet" + "name": "Austere Command" }, { "count": 1, - "name": "Sol Ring" + "name": "Fumigate" }, { "count": 1, - "name": "Pawn of Ulamog" + "name": "Vanquish the Horde" }, { "count": 1, - "name": "Sakura-Tribe Elder" + "name": "Mass Calcify" }, { "count": 1, - "name": "Springbloom Druid" + "name": "Armored Ascension" }, { "count": 1, - "name": "Wight of the Reliquary" + "name": "Folk Hero" }, { "count": 1, - "name": "Pitiless Plunderer" + "name": "Authority of the Consuls" }, { "count": 1, - "name": "Skull Prophet" + "name": "True Conviction" }, { "count": 1, - "name": "Insidious Roots" + "name": "Cathars' Crusade" }, { "count": 1, - "name": "Ashnod's Altar" + "name": "Monologue Tax" }, { "count": 1, - "name": "Bojuka Bog" + "name": "Leyline of Hope" }, { "count": 1, - "name": "Command Tower" + "name": "Adventurer's Inn" }, { "count": 1, - "name": "Exotic Orchard" + "name": "Path of Ancestry" }, { "count": 1, - "name": "Fabled Passage" + "name": "Radiant Fountain" }, { "count": 1, - "name": "Festering Thicket" + "name": "Rogue's Passage" }, { - "count": 8, - "name": "Forest" + "count": 1, + "name": "Secluded Steppe" }, { "count": 1, - "name": "Grim Backwoods" + "name": "Seraph Sanctuary" }, + { + "count": 30, + "name": "Plains" + } + ], + "sideboard": [] + }, + { + "name": "The Ur-Dragon", + "author": "MTGGoldfish", + "colors": [ + "G", + "U", + "R", + "W", + "B" + ], + "tags": [ + "metagame" + ], + "main": [ { "count": 1, - "name": "Haunted Mire" + "name": "All-Out Assault" }, { "count": 1, - "name": "High Market" + "name": "Crucible of Fire" }, { "count": 1, - "name": "Llanowar Wastes" + "name": "Karrthus, Tyrant of Jund" }, { "count": 1, - "name": "Necroblossom Snarl" + "name": "Stormscale Scion" }, { "count": 1, - "name": "Path of Ancestry" + "name": "Thunderclap Wyvern" }, { "count": 1, - "name": "Study Hall" + "name": "Obelisk of Alara" }, { - "count": 8, - "name": "Swamp" + "count": 1, + "name": "Darigaaz, the Igniter" }, { "count": 1, - "name": "Temple of Malady" + "name": "Twinflame Tyrant" }, { "count": 1, - "name": "Terramorphic Expanse" + "name": "Vorosh, the Hunter" }, { "count": 1, - "name": "Titan's Grave" + "name": "Font of Mythos" }, { "count": 1, - "name": "Turbulent Fen" + "name": "Intet, the Dreamer" }, { "count": 1, - "name": "Twilight Mire" + "name": "Kaalia, Zenith Seeker" }, { "count": 1, - "name": "Vernal Fen" + "name": "Korlessa, Scale Singer" }, { "count": 1, - "name": "Viridescent Bog" + "name": "Sarkhan Unbroken" }, { "count": 1, - "name": "Witherbloom Campus" + "name": "Coalition Victory" }, { "count": 1, - "name": "Woodland Cemetery" - } - ], - "sideboard": [] - }, - { - "name": "Lathril, Blade of the Elves", - "author": "MTGGoldfish", - "colors": [ - "G", - "B" - ], - "tags": [ - "metagame" - ], - "main": [ + "name": "Ancient Ziggurat" + }, { "count": 1, - "name": "Advancing the Spirit" + "name": "Arcane Sanctum" }, { "count": 1, - "name": "Awakening of Vitu-Ghazi" + "name": "Bloodfell Caves" }, { "count": 1, - "name": "Blackblade Reforged" + "name": "Command Tower" }, { "count": 1, - "name": "Bladed Battle-Fan" + "name": "Crystal Grotto" }, { "count": 1, - "name": "Bonesplitter" + "name": "Dismal Backwater" }, { - "count": 1, - "name": "Colossal Collision" + "count": 6, + "name": "Forest" }, { "count": 1, - "name": "Command Tower" + "name": "Haven of the Spirit Dragon" }, { "count": 1, - "name": "Cost of Brilliance" + "name": "Hidden Grotto" }, { - "count": 1, - "name": "Deathbloom Ritualist" + "count": 7, + "name": "Island" }, { "count": 1, - "name": "Deepwood Denizen" + "name": "Jungle Shrine" }, { - "count": 1, - "name": "Defiant Bloodlord" + "count": 6, + "name": "Mountain" }, { - "count": 1, - "name": "Dwynen, Gilt-Leaf Daen" + "count": 7, + "name": "Plains" }, { "count": 1, - "name": "Dwynen's Elite" + "name": "Reliquary Tower" }, { "count": 1, - "name": "Elvish Aberration" + "name": "Rugged Highlands" }, { "count": 1, - "name": "Elvish Archdruid" + "name": "Savage Lands" }, { "count": 1, - "name": "Elvish Doomsayer" + "name": "Seaside Citadel" + }, + { + "count": 6, + "name": "Swamp" }, { "count": 1, - "name": "Elvish Mystic" + "name": "Treva, the Renewer" }, { "count": 1, - "name": "Elvish Visionary" + "name": "Belltoll Dragon" }, { "count": 1, - "name": "Elvish Warmaster" + "name": "Call the Spirit Dragons" }, { "count": 1, - "name": "Explore" + "name": "Dragonlord Dromoka" }, { "count": 1, - "name": "Eyeblight Massacre" + "name": "Palladia-Mors, the Ruiner" }, { "count": 1, - "name": "Ezuri's Archers" + "name": "Scion of Draco" }, { "count": 1, - "name": "Fertile Thicket" + "name": "Sivitri, Dragon Master" }, { "count": 1, - "name": "Find // Finality" + "name": "Chromanticore" }, { "count": 1, - "name": "Forebear's Blade" + "name": "Arcane Signet" }, { - "count": 16, - "name": "Forest" + "count": 1, + "name": "Bloom Tender" }, { "count": 1, - "name": "Gilt-Leaf Winnower" + "name": "Dracogenesis" }, { "count": 1, - "name": "Gladehart Cavalry" + "name": "Dragon Arch" }, { "count": 1, - "name": "Heirloom Blade" + "name": "Dragonstorm Globe" }, { "count": 1, - "name": "Hero's Blade" + "name": "Kaalia of the Vast" }, { "count": 1, - "name": "Immaculate Magistrate" + "name": "Patriar's Seal" }, { "count": 1, - "name": "Imperious Perfect" + "name": "Rivaz of the Claw" }, { "count": 1, - "name": "Ivy Lane Denizen" + "name": "Sol Ring" }, { "count": 1, - "name": "Jiang Yanggu, Wildcrafter" + "name": "Timeless Lotus" }, { "count": 1, - "name": "Jungle Hollow" + "name": "Urza's Incubator" }, { "count": 1, - "name": "Leyline of Abundance" + "name": "Teneb, the Harvester" }, { "count": 1, - "name": "Leyline Prowler" + "name": "Bedevil" }, { "count": 1, - "name": "Llanowar Elves" + "name": "Child of Alara" }, { "count": 1, - "name": "Llanowar Scout" + "name": "Clip Wings" }, { "count": 1, - "name": "Morcant's Loyalist" + "name": "Crux of Fate" }, { "count": 1, - "name": "Nadier's Nightblade" + "name": "Dragon Tempest" }, { "count": 1, - "name": "Naturalize" + "name": "Farewell" }, { "count": 1, - "name": "Oran-Rief, the Vastwood" + "name": "Numot, the Devastator" }, { "count": 1, - "name": "Paradise Druid" + "name": "O-Kagachi, Vengeful Kami" }, { "count": 1, - "name": "Pennon Blade" + "name": "Ojutai, Soul of Winter" }, { "count": 1, - "name": "Pride of the Perfect" + "name": "Oros, the Avenger" }, { "count": 1, - "name": "Prowler's Helm" + "name": "Sultai Charm" }, { "count": 1, - "name": "Punishing Punch" + "name": "Terminate" }, { "count": 1, - "name": "Putrefy" + "name": "Where Ancients Tread" }, { "count": 1, - "name": "Reclamation Sage" + "name": "Wrathful Red Dragon" }, { "count": 1, - "name": "Restorative Technique" + "name": "Broodmate Dragon" }, { "count": 1, - "name": "Rhys the Exiled" + "name": "Miirym, Sentinel Wyrm" }, { "count": 1, - "name": "Rope" + "name": "Rith, the Awakener" }, { "count": 1, - "name": "Sage of the Maze" + "name": "Tiamat" }, { "count": 1, - "name": "Sanctuary Blade" + "name": "Rally of Wings" }, { "count": 1, - "name": "Savage Land Dinosaur" + "name": "The Ur-Dragon" + } + ], + "sideboard": [] + }, + { + "name": "Hashaton, Scarab's Fist", + "author": "MTGGoldfish", + "colors": [ + "W", + "B", + "U" + ], + "tags": [ + "metagame" + ], + "main": [ + { + "count": 1, + "name": "Animate Dead" }, { "count": 1, - "name": "Sentinel of Lost Lore" + "name": "Anointed Procession" }, { "count": 1, - "name": "Serpent Specialist" + "name": "Arcane Sanctum" }, { "count": 1, - "name": "Shaman of the Pack" + "name": "Arcane Signet" }, { "count": 1, - "name": "Shang-Chi, Martial Mentor" + "name": "Archfiend of Ifnir" }, { "count": 1, - "name": "Sigil of Valor" + "name": "Archon of Cruelty" }, { "count": 1, - "name": "Skarrg Goliath" + "name": "Ashen Rider" }, { "count": 1, - "name": "Storrev, Devkarin Lich" + "name": "Bitter Triumph" }, { - "count": 8, - "name": "Swamp" + "count": 1, + "name": "Bone Shards" }, { "count": 1, - "name": "Talara's Battalion" + "name": "Careful Study" }, { "count": 1, - "name": "Thorn Lieutenant" + "name": "Chart a Course" }, { "count": 1, - "name": "Training Regimen" + "name": "Command Tower" }, { "count": 1, - "name": "Treasured Find" + "name": "Consecrated Sphinx" }, { "count": 1, - "name": "Trystan's Command" + "name": "Cryptbreaker" }, { "count": 1, - "name": "Undercover Skrull" + "name": "Dark Ritual" }, { "count": 1, - "name": "Unknown Shores" + "name": "Deadly Rollick" }, { "count": 1, - "name": "Vastwood Fortification" + "name": "Deserted Beach" }, { "count": 1, - "name": "Viridian Emissary" + "name": "Drowned Catacomb" }, { "count": 1, - "name": "Warped Landscape" + "name": "Elesh Norn, Grand Cenobite" }, { "count": 1, - "name": "Wildborn Preserver" + "name": "Exalted Sunborn" }, { "count": 1, - "name": "Wren's Run Vanquisher" + "name": "Exotic Orchard" }, { "count": 1, - "name": "Yeva, Nature's Herald" + "name": "Faithful Mending" }, { "count": 1, - "name": "Lathril, Blade of the Elves" - } - ], - "sideboard": [] - }, - { - "name": "The Unbeatable Squirrel Girl", - "author": "MTGGoldfish", - "colors": [ - "G" - ], - "tags": [ - "metagame" - ], - "main": [ + "name": "Fierce Guardianship" + }, { "count": 1, - "name": "Valley Mightcaller" + "name": "Flooded Strand" }, { "count": 1, - "name": "Bakersbane Duo" + "name": "Frantic Search" }, { "count": 1, - "name": "Bushy Bodyguard" + "name": "Glacial Fortress" }, { "count": 1, - "name": "Scurrid Colony" + "name": "Godless Shrine" }, { "count": 1, - "name": "Frog-Squirrels" + "name": "Grave Titan" }, { "count": 1, - "name": "Squirrel Sovereign" + "name": "Gray Merchant of Asphodel" }, { "count": 1, - "name": "Thornvault Forager" + "name": "Grimoire of the Dead" }, { "count": 1, - "name": "Three Tree Mascot" + "name": "Hallowed Fountain" + }, + { + "count": 4, + "name": "Island" }, { "count": 1, - "name": "Barkform Harvester" + "name": "Isolated Chapel" }, { "count": 1, - "name": "Changeling Wayfinder" + "name": "Jin-Gitaxias, Core Augur" }, { "count": 1, - "name": "Chomping Changeling" + "name": "Kitsa, Otterball Elite" }, { "count": 1, - "name": "Curious Forager" + "name": "Likeness Looter" }, { "count": 1, - "name": "Honored Dreyleader" + "name": "Living Death" }, { "count": 1, - "name": "Peregrin Took" + "name": "Macabre Waltz" }, { "count": 1, - "name": "Supportive Parents" + "name": "Mana Drain" }, { "count": 1, - "name": "Squirrel Mob" + "name": "Marsh Flats" }, { "count": 1, - "name": "Karametra's Acolyte" + "name": "Matzalantli, the Great Door" }, { "count": 1, - "name": "Keeper of Progenitus" + "name": "Merfolk Looter" }, { "count": 1, - "name": "Citanul Hierophants" + "name": "Metamorphosis Fanatic" }, { "count": 1, - "name": "Nullmage Shepherd" + "name": "Mondrak, Glory Dominus" }, { "count": 1, - "name": "Treetop Sentries" + "name": "Monument to Endurance" }, { "count": 1, - "name": "Deep Forest Hermit" + "name": "Morphic Pool" }, { "count": 1, - "name": "Groundchuck & Dirtbag" + "name": "Obsessive Stitcher" }, { "count": 1, - "name": "Might of the Masses" + "name": "Ojer Taq, Deepest Foundation" }, { "count": 1, - "name": "Chatter of the Squirrel" + "name": "Oversold Cemetery" }, { "count": 1, - "name": "Snakeskin Veil" + "name": "Path of Ancestry" }, { "count": 1, - "name": "Rampant Growth" + "name": "Peregrine Drake" + }, + { + "count": 3, + "name": "Plains" }, { "count": 1, - "name": "Origin of Metalbending" + "name": "Polluted Delta" }, { "count": 1, - "name": "Shared Roots" + "name": "Prairie Stream" }, { "count": 1, - "name": "Chatterstorm" + "name": "Psychic Frog" }, { "count": 1, - "name": "Grow from the Ashes" + "name": "Raffine's Tower" }, { "count": 1, - "name": "Cycle of Renewal" + "name": "Reanimate" }, { "count": 1, - "name": "Gaea's Bounty" + "name": "Reliquary Tower" }, { "count": 1, - "name": "Harmonize" + "name": "Renewed Solidarity" }, { "count": 1, - "name": "Vastwood Surge" + "name": "Rhet-Tomb Mystic" }, { "count": 1, - "name": "Migration Path" + "name": "Rhystic Study" }, { "count": 1, - "name": "Zimone's Experiment" + "name": "Rise of the Dark Realms" }, { "count": 1, - "name": "Overwhelming Encounter" + "name": "Rona, Herald of Invasion" }, { "count": 1, - "name": "Rude Awakening" + "name": "Rootborn Defenses" }, { "count": 1, - "name": "Overrun" + "name": "Sea of Clouds" }, { "count": 1, - "name": "Zuko's Exile" + "name": "Shattered Sanctum" }, { "count": 1, - "name": "Vivien's Stampede" + "name": "Sheoldred, Whispering One" }, { "count": 1, - "name": "Collective Unconscious" + "name": "Shipwreck Marsh" }, { "count": 1, - "name": "Klothys's Design" + "name": "Shorikai, Genesis Engine" }, { "count": 1, - "name": "Mask of Avacyn" + "name": "Skirge Familiar" }, { "count": 1, - "name": "Chariot of Victory" + "name": "Sol Ring" }, { "count": 1, - "name": "Chitterspitter" + "name": "Sunken Hollow" + }, + { + "count": 4, + "name": "Swamp" }, { "count": 1, - "name": "Firdoch Core" + "name": "Swords to Plowshares" }, { "count": 1, - "name": "Heaped Harvest" + "name": "Takenuma, Abandoned Mire" }, { "count": 1, - "name": "Unstable Obelisk" + "name": "Temmet, Naktamun's Will" }, { "count": 1, - "name": "Symmetry Matrix" + "name": "The Scarab God" }, { "count": 1, - "name": "Slate of Ancestry" + "name": "The Soul Stone" }, { "count": 1, - "name": "Wolfwillow Haven" + "name": "The Underworld Cookbook" }, { "count": 1, - "name": "Shimmerwilds Growth" + "name": "Thirst for Identity" }, { "count": 1, - "name": "Fertile Ground" + "name": "Three Steps Ahead" }, { "count": 1, - "name": "Overwhelming Instinct" + "name": "Tortured Existence" }, { "count": 1, - "name": "Squirrel Nest" + "name": "Toxrill, the Corrosive" }, { "count": 1, - "name": "Frontier Siege" + "name": "Unholy Grotto" }, { "count": 1, - "name": "Wilderness Reclamation" + "name": "Valgavoth, Terror Eater" }, { "count": 1, - "name": "Dictate of Karametra" + "name": "Vault of Champions" }, { "count": 1, - "name": "A Realm Reborn" + "name": "Victimize" }, { - "count": 35, - "name": "Forest" + "count": 1, + "name": "Vohar, Vodalian Desecrator" }, { "count": 1, - "name": "Oakhollow Village" + "name": "Watery Grave" }, { "count": 1, - "name": "Oran-Rief, the Vastwood" + "name": "Winternight Stories" }, { "count": 1, - "name": "The Unbeatable Squirrel Girl" + "name": "Hashaton, Scarab's Fist" }, { "count": 1, - "name": "Heirloom Epic" + "name": "Cyclonic Rift" } ], "sideboard": [] }, { - "name": "Hashaton, Scarab's Fist", + "name": "Meren of Clan Nel Toth", "author": "MTGGoldfish", "colors": [ - "W", - "B", - "U" + "G", + "B" ], "tags": [ "metagame" @@ -2773,382 +2792,356 @@ "main": [ { "count": 1, - "name": "Animate Dead" - }, - { - "count": 1, - "name": "Anointed Procession" - }, - { - "count": 1, - "name": "Arcane Sanctum" - }, - { - "count": 1, - "name": "Arcane Signet" - }, - { - "count": 1, - "name": "Archfiend of Ifnir" - }, - { - "count": 1, - "name": "Archon of Cruelty" + "name": "Meren of Clan Nel Toth" }, { "count": 1, - "name": "Ashen Rider" + "name": "Protean Hulk" }, { "count": 1, - "name": "Bitter Triumph" + "name": "Gray Merchant of Asphodel" }, { "count": 1, - "name": "Bone Shards" + "name": "Defiling Daemogoth" }, { "count": 1, - "name": "Careful Study" + "name": "Eccentric Pestfinder" }, { "count": 1, - "name": "Chart a Course" + "name": "Blight Mound" }, { "count": 1, - "name": "Command Tower" + "name": "Pest Rescuer" }, { "count": 1, - "name": "Consecrated Sphinx" + "name": "Ribtruss Roaster" }, { "count": 1, - "name": "Cryptbreaker" + "name": "Feral Appetite" }, { "count": 1, - "name": "Dark Ritual" + "name": "Trudge Garden" }, { "count": 1, - "name": "Deadly Rollick" + "name": "Beledros Witherbloom" }, { "count": 1, - "name": "Deserted Beach" + "name": "Grave Researcher" }, { "count": 1, - "name": "Drowned Catacomb" + "name": "Witch of the Moors" }, { "count": 1, - "name": "Elesh Norn, Grand Cenobite" + "name": "Veinwitch Coven" }, { "count": 1, - "name": "Exalted Sunborn" + "name": "Deadly Brew" }, { "count": 1, - "name": "Exotic Orchard" + "name": "Old Stickfingers" }, { "count": 1, - "name": "Faithful Mending" + "name": "Satyr Wayfinder" }, { "count": 1, - "name": "Fierce Guardianship" + "name": "Zulaport Cutthroat" }, { "count": 1, - "name": "Flooded Strand" + "name": "Blood Artist" }, { "count": 1, - "name": "Frantic Search" + "name": "Blossoming Bogbeast" }, { "count": 1, - "name": "Glacial Fortress" + "name": "Spore Frog" }, { "count": 1, - "name": "Godless Shrine" + "name": "Assassin's Trophy" }, { "count": 1, - "name": "Grave Titan" + "name": "Foundation Breaker" }, { "count": 1, - "name": "Gray Merchant of Asphodel" + "name": "Haywire Mite" }, { "count": 1, - "name": "Grimoire of the Dead" + "name": "Infernal Grasp" }, { "count": 1, - "name": "Hallowed Fountain" - }, - { - "count": 4, - "name": "Island" + "name": "Shriekmaw" }, { "count": 1, - "name": "Isolated Chapel" + "name": "Reclamation Sage" }, { "count": 1, - "name": "Jin-Gitaxias, Core Augur" + "name": "Plagued Rusalka" }, { "count": 1, - "name": "Kitsa, Otterball Elite" + "name": "Brood Butcher" }, { "count": 1, - "name": "Likeness Looter" + "name": "Mortality Spear" }, { "count": 1, - "name": "Living Death" + "name": "Ravenous Chupacabra" }, { "count": 1, - "name": "Macabre Waltz" + "name": "Immoral Bargain" }, { "count": 1, - "name": "Mana Drain" + "name": "Final Act" }, { "count": 1, - "name": "Marsh Flats" + "name": "Toxic Deluge" }, { "count": 1, - "name": "Matzalantli, the Great Door" + "name": "Pest Infestation" }, { "count": 1, - "name": "Merfolk Looter" + "name": "Massacre Girl" }, { "count": 1, - "name": "Metamorphosis Fanatic" + "name": "Vampiric Rites" }, { "count": 1, - "name": "Mondrak, Glory Dominus" + "name": "Morbid Opportunist" }, { "count": 1, - "name": "Monument to Endurance" + "name": "Smothering Abomination" }, { "count": 1, - "name": "Morphic Pool" + "name": "Umbral Collar Zealot" }, { "count": 1, - "name": "Obsessive Stitcher" + "name": "Viscera Seer" }, { "count": 1, - "name": "Ojer Taq, Deepest Foundation" + "name": "Woe Strider" }, { "count": 1, - "name": "Oversold Cemetery" + "name": "Grim Haruspex" }, { "count": 1, - "name": "Path of Ancestry" + "name": "Midnight Reaper" }, { "count": 1, - "name": "Peregrine Drake" + "name": "Deathreap Ritual" }, { - "count": 3, - "name": "Plains" + "count": 1, + "name": "Moldervine Reclamation" }, { "count": 1, - "name": "Polluted Delta" + "name": "Unmarked Grave" }, { "count": 1, - "name": "Prairie Stream" + "name": "Buried Alive" }, { "count": 1, - "name": "Psychic Frog" + "name": "Jarad's Orders" }, { "count": 1, - "name": "Raffine's Tower" + "name": "Fauna Shaman" }, { "count": 1, - "name": "Reanimate" + "name": "Vile Entomber" }, { "count": 1, - "name": "Reliquary Tower" + "name": "Birthing Pod" }, { "count": 1, - "name": "Renewed Solidarity" + "name": "Grisly Salvage" }, { "count": 1, - "name": "Rhet-Tomb Mystic" + "name": "Arcane Signet" }, { "count": 1, - "name": "Rhystic Study" + "name": "Sol Ring" }, { "count": 1, - "name": "Rise of the Dark Realms" + "name": "Pawn of Ulamog" }, { "count": 1, - "name": "Rona, Herald of Invasion" + "name": "Sakura-Tribe Elder" }, { "count": 1, - "name": "Rootborn Defenses" + "name": "Springbloom Druid" }, { "count": 1, - "name": "Sea of Clouds" + "name": "Wight of the Reliquary" }, { "count": 1, - "name": "Shattered Sanctum" + "name": "Pitiless Plunderer" }, { "count": 1, - "name": "Sheoldred, Whispering One" + "name": "Skull Prophet" }, { "count": 1, - "name": "Shipwreck Marsh" + "name": "Insidious Roots" }, { "count": 1, - "name": "Shorikai, Genesis Engine" + "name": "Ashnod's Altar" }, { "count": 1, - "name": "Skirge Familiar" + "name": "Bojuka Bog" }, { "count": 1, - "name": "Sol Ring" + "name": "Command Tower" }, { "count": 1, - "name": "Sunken Hollow" + "name": "Exotic Orchard" }, { - "count": 4, - "name": "Swamp" + "count": 1, + "name": "Fabled Passage" }, { "count": 1, - "name": "Swords to Plowshares" + "name": "Festering Thicket" }, { - "count": 1, - "name": "Takenuma, Abandoned Mire" + "count": 8, + "name": "Forest" }, { "count": 1, - "name": "Temmet, Naktamun's Will" + "name": "Grim Backwoods" }, { "count": 1, - "name": "The Scarab God" + "name": "Haunted Mire" }, { "count": 1, - "name": "The Soul Stone" + "name": "High Market" }, { "count": 1, - "name": "The Underworld Cookbook" + "name": "Llanowar Wastes" }, { "count": 1, - "name": "Thirst for Identity" + "name": "Necroblossom Snarl" }, { "count": 1, - "name": "Three Steps Ahead" + "name": "Path of Ancestry" }, { "count": 1, - "name": "Tortured Existence" + "name": "Study Hall" }, { - "count": 1, - "name": "Toxrill, the Corrosive" + "count": 8, + "name": "Swamp" }, { "count": 1, - "name": "Unholy Grotto" + "name": "Temple of Malady" }, { "count": 1, - "name": "Valgavoth, Terror Eater" + "name": "Terramorphic Expanse" }, { "count": 1, - "name": "Vault of Champions" + "name": "Titan's Grave" }, { "count": 1, - "name": "Victimize" + "name": "Turbulent Fen" }, { "count": 1, - "name": "Vohar, Vodalian Desecrator" + "name": "Twilight Mire" }, { "count": 1, - "name": "Watery Grave" + "name": "Vernal Fen" }, { "count": 1, - "name": "Winternight Stories" + "name": "Viridescent Bog" }, { "count": 1, - "name": "Hashaton, Scarab's Fist" + "name": "Witherbloom Campus" }, { "count": 1, - "name": "Cyclonic Rift" + "name": "Woodland Cemetery" } ], "sideboard": [] }, { - "name": "Kaalia of the Vast", + "name": "Magda, Brazen Outlaw", "author": "MTGGoldfish", "colors": [ - "W", - "R", - "B" + "R" ], "tags": [ "metagame" @@ -3156,351 +3149,315 @@ "main": [ { "count": 1, - "name": "Foreboding Ruins" - }, - { - "count": 1, - "name": "Victimize" - }, - { - "count": 1, - "name": "Gurmag Rakshasa" - }, - { - "count": 1, - "name": "Vampiric Dragon" - }, - { - "count": 1, - "name": "Blast-Furnace Hellkite" - }, - { - "count": 1, - "name": "Bladewing, Deathless Tyrant" + "name": "Magda, Brazen Outlaw" }, { "count": 1, - "name": "Angel of Invention" + "name": "Axgard Cavalry" }, { "count": 1, - "name": "Emeria Angel" - }, - { - "count": 1, - "name": "Angel of Finality" + "name": "Universal Automaton" }, { "count": 1, - "name": "Dragonstorm Globe" + "name": "Bloodline Pretender" }, { "count": 1, - "name": "Inevitable Defeat" + "name": "Taurean Mauler" }, { "count": 1, - "name": "Rakdos Charm" + "name": "Torbran, Thane of Red Fell" }, { "count": 1, - "name": "Marshal's Anthem" + "name": "Krark, the Thumbless" }, { "count": 1, - "name": "Rugged Prairie" + "name": "Fearless Liberator" }, { "count": 1, - "name": "Maelstrom of the Spirit Dragon" + "name": "Vault Robber" }, { "count": 1, - "name": "Evolving Wilds" + "name": "Amber Gristle O'Maul" }, { "count": 1, - "name": "Bontu's Monument" + "name": "Dwarven Miner" }, { "count": 1, - "name": "Rakdos Signet" + "name": "Dwarven Blastminer" }, { "count": 1, - "name": "Mind Stone" + "name": "Dwarven Berserker" }, { "count": 1, - "name": "Bedevil" + "name": "Dwarven Trader" }, { "count": 1, - "name": "Heirloom Blade" + "name": "Dwarven Grunt" }, { "count": 1, - "name": "Scoured Barrens" + "name": "Dwarven Nomad" }, { "count": 1, - "name": "Terramorphic Expanse" + "name": "Dwarven Warriors" }, { "count": 1, - "name": "Sunlit Marsh" + "name": "Dwarven Scorcher" }, { "count": 1, - "name": "Concealed Courtyard" + "name": "Bloodfire Dwarf" }, { "count": 1, - "name": "Akoum Refuge" + "name": "Spark Mage" }, { "count": 1, - "name": "Demonic Counsel" + "name": "Plundering Barbarian" }, { "count": 1, - "name": "Desecration Demon" + "name": "Rimrock Knight" }, { "count": 1, - "name": "Arbiter of Woe" + "name": "Insufferable Balladeer" }, { "count": 1, - "name": "Steel Hellkite" + "name": "Storm-Kiln Artist" }, { "count": 1, - "name": "Sephara, Sky's Blade" + "name": "Professional Face-Breaker" }, { "count": 1, - "name": "Restoration Angel" + "name": "Captain Lannery Storm" }, { "count": 1, - "name": "Utvara Hellkite" + "name": "Xorn" }, { "count": 1, - "name": "Capricious Hellraiser" + "name": "Reckless Fireweaver" }, { "count": 1, - "name": "Nesting Dragon" + "name": "Ingenious Artillerist" }, { "count": 1, - "name": "Decadent Dragon" + "name": "Goldspan Dragon" }, { "count": 1, - "name": "Angel of Sanctions" + "name": "Gadrak, the Crown-Scourge" }, { "count": 1, - "name": "Angel of the Ruins" + "name": "Rapacious Dragon" }, { "count": 1, - "name": "Steel Seraph" + "name": "Hellkite Igniter" }, { "count": 1, - "name": "Angel of Serenity" + "name": "Hellkite Tyrant" }, { "count": 1, - "name": "Aegis Angel" + "name": "Steel Hellkite" }, { "count": 1, - "name": "Lightning Greaves" + "name": "Terror of Mount Velus" }, { "count": 1, - "name": "Moment of Reckoning" + "name": "Combustible Gearhulk" }, { "count": 1, - "name": "Kothophed, Soul Hoarder" + "name": "Myr Battlesphere" }, { "count": 1, - "name": "Valgavoth, Terror Eater" + "name": "Meteor Golem" }, { "count": 1, - "name": "Warmonger Hellkite" + "name": "Solemn Simulacrum" }, { "count": 1, - "name": "Fields of Strife" + "name": "Burnished Hart" }, { "count": 1, - "name": "Youthful Valkyrie" + "name": "Sol Ring" }, { "count": 1, - "name": "Orzhov Guildgate" + "name": "Arcane Signet" }, { "count": 1, - "name": "Dragonskull Summit" + "name": "Mind Stone" }, { "count": 1, - "name": "Exotic Orchard" + "name": "Fellwar Stone" }, { "count": 1, - "name": "Thriving Bluff" + "name": "Wayfarer's Bauble" }, { "count": 1, - "name": "Read the Bones" + "name": "Springleaf Drum" }, { "count": 1, - "name": "The Balrog, Durin's Bane" + "name": "Clock of Omens" }, { "count": 1, - "name": "Hezrou" + "name": "Skullclamp" }, { "count": 1, - "name": "Murder" + "name": "Swiftfoot Boots" }, { "count": 1, - "name": "Breaching Dragonstorm" + "name": "Idol of Oblivion" }, { "count": 1, - "name": "There and Back Again" + "name": "Coveted Jewel" }, { "count": 1, - "name": "Sonic Screwdriver" + "name": "Goldvein Pick" }, { "count": 1, - "name": "Orim's Thunder" + "name": "Big Score" }, { "count": 1, - "name": "Take Up the Shield" + "name": "Pirate's Pillage" }, { "count": 1, - "name": "Barrensteppe Siege" + "name": "Brass's Bounty" }, { "count": 1, - "name": "Temple of Malice" + "name": "Seize the Spoils" }, { "count": 1, - "name": "Strionic Resonator" + "name": "Abrade" }, { "count": 1, - "name": "Hazoret's Monument" + "name": "Chaos Warp" }, { "count": 1, - "name": "Fellwar Stone" + "name": "Vandalblast" }, { "count": 1, - "name": "Pyretic Rebirth" + "name": "Blasphemous Act" }, { "count": 1, - "name": "Sundown Pass" + "name": "Fiery Confluence" }, { "count": 1, - "name": "Thriving Moor" + "name": "Jaya's Immolating Inferno" }, { "count": 1, - "name": "Aerial Assault" + "name": "Goblin Bombardment" }, { "count": 1, - "name": "Pestilence Demon" + "name": "Curse of Opulence" }, { "count": 1, - "name": "Valkyrie Harbinger" + "name": "Dwarven Mine" }, { "count": 1, - "name": "Immersturm Predator" + "name": "Holdout Settlement" }, { "count": 1, - "name": "Ghostly Prison" + "name": "Survivors' Encampment" }, { "count": 1, - "name": "Anguished Unmaking" + "name": "Darksteel Citadel" }, { "count": 1, - "name": "Gods Willing" + "name": "Treasure Vault" }, { "count": 1, - "name": "Sol Ring" + "name": "Buried Ruin" }, { "count": 1, - "name": "Thriving Heath" + "name": "Rogue's Passage" }, { "count": 1, - "name": "Command Tower" + "name": "Path of Ancestry" }, { "count": 1, - "name": "Shattered Sanctum" + "name": "Castle Embereth" }, { "count": 1, - "name": "Wind-Scarred Crag" + "name": "Ramunap Ruins" }, { "count": 1, - "name": "Neriv, Heart of the Storm" + "name": "Ghitu Encampment" }, { "count": 1, - "name": "Terror of Mount Velus" - }, - { - "count": 6, - "name": "Plains" + "name": "Forgotten Cave" }, { - "count": 4, + "count": 23, "name": "Mountain" - }, - { - "count": 6, - "name": "Swamp" - }, - { - "count": 1, - "name": "Kaalia of the Vast" } ], "sideboard": [] diff --git a/client/public/feeds/mtggoldfish-modern.json b/client/public/feeds/mtggoldfish-modern.json index 729c5a9aa7..72fa497765 100644 --- a/client/public/feeds/mtggoldfish-modern.json +++ b/client/public/feeds/mtggoldfish-modern.json @@ -5,7 +5,7 @@ "icon": "G", "format": "modern", "version": 1, - "updated": "2026-08-09T00:00:00Z", + "updated": "2026-08-10T00:00:00Z", "source": "https://www.mtggoldfish.com/metagame/modern", "decks": [ { diff --git a/client/public/feeds/mtggoldfish-pioneer.json b/client/public/feeds/mtggoldfish-pioneer.json index 15b8cd2591..a517a88db4 100644 --- a/client/public/feeds/mtggoldfish-pioneer.json +++ b/client/public/feeds/mtggoldfish-pioneer.json @@ -5,7 +5,7 @@ "icon": "G", "format": "pioneer", "version": 1, - "updated": "2026-08-09T00:00:00Z", + "updated": "2026-08-10T00:00:00Z", "source": "https://www.mtggoldfish.com/metagame/pioneer", "decks": [ { @@ -441,113 +441,97 @@ "main": [ { "count": 4, - "name": "Mountain" + "name": "Burst Lightning" }, { "count": 1, - "name": "Mountain" + "name": "Den of the Bugbear" }, { - "count": 1, - "name": "Mountain" + "count": 4, + "name": "Emberheart Challenger" }, { - "count": 1, - "name": "Mountain" + "count": 4, + "name": "Kumano Faces Kakkazan" }, { - "count": 4, - "name": "Soul-Scar Mage" + "count": 2, + "name": "Eidolon of the Great Revel" }, { - "count": 3, - "name": "Ramunap Ruins" + "count": 4, + "name": "Monastery Swiftspear" }, { "count": 3, - "name": "Reckless Rage" + "name": "Monstrous Rage" }, { - "count": 4, - "name": "Bonecrusher Giant" + "count": 15, + "name": "Mountain" }, { "count": 2, - "name": "Den of the Bugbear" - }, - { - "count": 4, - "name": "Kumano Faces Kakkazan" + "name": "Ramunap Ruins" }, { - "count": 1, - "name": "Sokenzan, Crucible of Defiance" + "count": 3, + "name": "Reckless Rage" }, { "count": 2, - "name": "Monstrous Rage" - }, - { - "count": 4, - "name": "Emberheart Challenger" + "name": "Screaming Nemesis" }, { "count": 1, - "name": "Rockface Village" + "name": "Sokenzan, Crucible of Defiance" }, { - "count": 1, - "name": "Mountain" + "count": 3, + "name": "Soul-Scar Mage" }, { - "count": 4, - "name": "Screaming Nemesis" + "count": 3, + "name": "Sunspine Lynx" }, { - "count": 4, - "name": "Mountain" + "count": 2, + "name": "Screaming Nemesis" }, { "count": 4, "name": "Mutavault" }, { - "count": 4, - "name": "Burst Lightning" - }, - { - "count": 4, - "name": "Monastery Swiftspear" + "count": 1, + "name": "Sunspine Lynx" }, { - "count": 4, - "name": "Sunspine Lynx" + "count": 2, + "name": "Flowstone Infusion" } ], "sideboard": [ { - "count": 2, - "name": "Pyroclasm" - }, - { - "count": 2, - "name": "Redcap Melee" + "count": 3, + "name": "Scorching Shot" }, { - "count": 2, - "name": "Weathered Runestone" + "count": 4, + "name": "Magebane Lizard" }, { "count": 3, - "name": "Flowstone Infusion" + "name": "Pyroclasm" }, { - "count": 4, - "name": "Magebane Lizard" + "count": 2, + "name": "Rending Volley" }, { - "count": 2, - "name": "Scorching Shot" + "count": 3, + "name": "Weathered Runestone" } ] }, @@ -702,28 +686,32 @@ ], "main": [ { - "count": 4, + "count": 5, "name": "Island" }, { - "count": 2, - "name": "Abandon Attachments" + "count": 4, + "name": "Gran-Gran" }, { - "count": 3, - "name": "Firebending Lesson" + "count": 1, + "name": "Otawara, Soaring City" }, { - "count": 3, - "name": "Combustion Technique" + "count": 4, + "name": "Stormchaser's Talent" }, { "count": 2, - "name": "It'll Quench Ya!" + "name": "Ral, Crackling Wit" }, { - "count": 4, - "name": "Gran-Gran" + "count": 1, + "name": "Torch the Tower" + }, + { + "count": 2, + "name": "Accumulate Wisdom" }, { "count": 3, @@ -731,43 +719,47 @@ }, { "count": 2, - "name": "Accumulate Wisdom" + "name": "Vivi Ornitier" }, { - "count": 2, - "name": "Vivi Ornitier" + "count": 4, + "name": "Riverglide Pathway" }, { - "count": 2, - "name": "Riverpyre Verge" + "count": 4, + "name": "Consider" }, { - "count": 1, - "name": "Spell Pierce" + "count": 3, + "name": "Combustion Technique" }, { - "count": 1, - "name": "Torch the Tower" + "count": 3, + "name": "Firebending Lesson" }, { "count": 1, - "name": "Otawara, Soaring City" + "name": "Spell Pierce" }, { - "count": 4, - "name": "Consider" + "count": 2, + "name": "Shivan Reef" }, { "count": 2, - "name": "Igneous Inspiration" + "name": "Abandon Attachments" }, { - "count": 4, - "name": "Divide by Zero" + "count": 1, + "name": "Riverpyre Verge" }, { - "count": 4, - "name": "Riverglide Pathway" + "count": 2, + "name": "It'll Quench Ya!" + }, + { + "count": 2, + "name": "Igneous Inspiration" }, { "count": 4, @@ -777,17 +769,9 @@ "count": 4, "name": "Spirebluff Canal" }, - { - "count": 2, - "name": "Ral, Crackling Wit" - }, - { - "count": 2, - "name": "Shivan Reef" - }, { "count": 4, - "name": "Stormchaser's Talent" + "name": "Divide by Zero" } ], "sideboard": [ @@ -796,12 +780,12 @@ "name": "Flashfreeze" }, { - "count": 1, - "name": "Iroh's Demonstration" + "count": 2, + "name": "Abrade" }, { - "count": 1, - "name": "Firebending Lesson" + "count": 2, + "name": "Accumulate Wisdom" }, { "count": 1, @@ -812,16 +796,16 @@ "name": "Boomerang Basics" }, { - "count": 2, - "name": "Accumulate Wisdom" + "count": 1, + "name": "Iroh's Demonstration" }, { - "count": 2, - "name": "Abrade" + "count": 1, + "name": "Firebending Lesson" }, { "count": 2, - "name": "Soul-Guide Lantern" + "name": "Quantum Riddler" }, { "count": 1, @@ -829,7 +813,7 @@ }, { "count": 2, - "name": "Quantum Riddler" + "name": "Soul-Guide Lantern" }, { "count": 1, diff --git a/client/public/feeds/mtggoldfish-standard.json b/client/public/feeds/mtggoldfish-standard.json index 416e94868f..bfff835fca 100644 --- a/client/public/feeds/mtggoldfish-standard.json +++ b/client/public/feeds/mtggoldfish-standard.json @@ -5,7 +5,7 @@ "icon": "G", "format": "standard", "version": 1, - "updated": "2026-08-09T00:00:00Z", + "updated": "2026-08-10T00:00:00Z", "source": "https://www.mtggoldfish.com/metagame/standard", "decks": [ { @@ -147,6 +147,138 @@ } ] }, + { + "name": "Jeskai Lessons", + "author": "MTGGoldfish", + "colors": [ + "W", + "U", + "R" + ], + "tags": [ + "metagame" + ], + "main": [ + { + "count": 1, + "name": "Mistrise Village" + }, + { + "count": 4, + "name": "Gran-Gran" + }, + { + "count": 1, + "name": "Three Steps Ahead" + }, + { + "count": 1, + "name": "Flashback" + }, + { + "count": 4, + "name": "Great Hall of the Biblioplex" + }, + { + "count": 3, + "name": "Hallowed Fountain" + }, + { + "count": 4, + "name": "Jeskai Revelation" + }, + { + "count": 2, + "name": "Sundown Pass" + }, + { + "count": 4, + "name": "Combustion Technique" + }, + { + "count": 3, + "name": "Island" + }, + { + "count": 2, + "name": "Iroh's Demonstration" + }, + { + "count": 4, + "name": "Firebending Lesson" + }, + { + "count": 4, + "name": "Accumulate Wisdom" + }, + { + "count": 3, + "name": "Stock Up" + }, + { + "count": 4, + "name": "Abandon Attachments" + }, + { + "count": 2, + "name": "It'll Quench Ya!" + }, + { + "count": 3, + "name": "Riverpyre Verge" + }, + { + "count": 4, + "name": "Steam Vents" + }, + { + "count": 4, + "name": "Tablet of Discovery" + }, + { + "count": 3, + "name": "Spirebluff Canal" + } + ], + "sideboard": [ + { + "count": 1, + "name": "Disdainful Stroke" + }, + { + "count": 1, + "name": "Flashfreeze" + }, + { + "count": 4, + "name": "Price of Freedom" + }, + { + "count": 1, + "name": "Avengers Disassembled" + }, + { + "count": 1, + "name": "Ghost Vacuum" + }, + { + "count": 2, + "name": "Day of Judgment" + }, + { + "count": 2, + "name": "Spell Pierce" + }, + { + "count": 2, + "name": "Emeritus of Ideation" + }, + { + "count": 1, + "name": "Ral, Crackling Wit" + } + ] + }, { "name": "Selesnya Ouroboroid", "author": "MTGGoldfish", @@ -334,138 +466,6 @@ } ] }, - { - "name": "Jeskai Lessons", - "author": "MTGGoldfish", - "colors": [ - "W", - "U", - "R" - ], - "tags": [ - "metagame" - ], - "main": [ - { - "count": 1, - "name": "Mistrise Village" - }, - { - "count": 4, - "name": "Gran-Gran" - }, - { - "count": 1, - "name": "Three Steps Ahead" - }, - { - "count": 1, - "name": "Flashback" - }, - { - "count": 4, - "name": "Great Hall of the Biblioplex" - }, - { - "count": 3, - "name": "Hallowed Fountain" - }, - { - "count": 4, - "name": "Jeskai Revelation" - }, - { - "count": 2, - "name": "Sundown Pass" - }, - { - "count": 4, - "name": "Combustion Technique" - }, - { - "count": 3, - "name": "Island" - }, - { - "count": 2, - "name": "Iroh's Demonstration" - }, - { - "count": 4, - "name": "Firebending Lesson" - }, - { - "count": 4, - "name": "Accumulate Wisdom" - }, - { - "count": 3, - "name": "Stock Up" - }, - { - "count": 4, - "name": "Abandon Attachments" - }, - { - "count": 2, - "name": "It'll Quench Ya!" - }, - { - "count": 3, - "name": "Riverpyre Verge" - }, - { - "count": 4, - "name": "Steam Vents" - }, - { - "count": 4, - "name": "Tablet of Discovery" - }, - { - "count": 3, - "name": "Spirebluff Canal" - } - ], - "sideboard": [ - { - "count": 1, - "name": "Disdainful Stroke" - }, - { - "count": 1, - "name": "Flashfreeze" - }, - { - "count": 4, - "name": "Price of Freedom" - }, - { - "count": 1, - "name": "Avengers Disassembled" - }, - { - "count": 1, - "name": "Ghost Vacuum" - }, - { - "count": 2, - "name": "Day of Judgment" - }, - { - "count": 2, - "name": "Spell Pierce" - }, - { - "count": 2, - "name": "Emeritus of Ideation" - }, - { - "count": 1, - "name": "Ral, Crackling Wit" - } - ] - }, { "name": "Izzet Spellementals", "author": "MTGGoldfish", @@ -593,18 +593,18 @@ "name": "4c Control", "author": "MTGGoldfish", "colors": [ - "W", - "B", "U", - "R" + "R", + "W", + "B" ], "tags": [ "metagame" ], "main": [ { - "count": 1, - "name": "North Wind Avatar" + "count": 2, + "name": "Together as One" }, { "count": 4, @@ -612,18 +612,14 @@ }, { "count": 1, - "name": "Sundown Pass" + "name": "Spell Snare" }, { - "count": 1, - "name": "Sunbillow Verge" + "count": 2, + "name": "Shattered Sanctum" }, { "count": 3, - "name": "Consult the Star Charts" - }, - { - "count": 2, "name": "Firebending Lesson" }, { @@ -632,113 +628,101 @@ }, { "count": 1, - "name": "Stormcarved Coast" - }, - { - "count": 1, - "name": "Plains" - }, - { - "count": 1, - "name": "Godless Shrine" + "name": "Island" }, { "count": 1, - "name": "Cori Mountain Monastery" + "name": "Pest Control" }, { "count": 2, - "name": "Shattered Sanctum" - }, - { - "count": 1, - "name": "Mistrise Village" + "name": "Sacred Foundry" }, { "count": 4, "name": "Great Hall of the Biblioplex" }, { - "count": 2, - "name": "Hallowed Fountain" - }, - { - "count": 2, - "name": "Gloomlake Verge" + "count": 3, + "name": "Riverpyre Verge" }, { "count": 3, - "name": "Sacred Foundry" + "name": "Bleachbone Verge" }, { "count": 4, - "name": "Steam Vents" + "name": "Stock Up" }, { "count": 2, - "name": "Day of Judgment" + "name": "Deadly Cover-Up" }, { - "count": 4, - "name": "Stock Up" + "count": 2, + "name": "Undercity Sewers" }, { "count": 1, - "name": "Negate" + "name": "Duress" }, { "count": 2, - "name": "Sear" + "name": "Nowhere to Run" }, { - "count": 1, - "name": "Meticulous Archive" + "count": 2, + "name": "Sear" }, { - "count": 1, - "name": "Multiversal Passage" + "count": 4, + "name": "Blood Crypt" }, { "count": 2, - "name": "No More Lies" + "name": "Starting Town" + }, + { + "count": 1, + "name": "Swamp" }, { "count": 2, - "name": "Lightning Helix" + "name": "Shoot the Sheriff" }, { "count": 4, "name": "Jeskai Revelation" }, { - "count": 4, + "count": 2, "name": "Inevitable Defeat" }, { "count": 1, - "name": "Island" + "name": "Plains" + }, + { + "count": 1, + "name": "Steam Vents" + }, + { + "count": 2, + "name": "Consult the Star Charts" } ], "sideboard": [ - { - "count": 2, - "name": "Emeritus of Ideation" - }, { "count": 1, - "name": "Disdainful Stroke" + "name": "Return the Favor" }, { "count": 1, - "name": "Day of Judgment" + "name": "Disdainful Stroke" }, { "count": 2, - "name": "Strategic Betrayal" - }, - { - "count": 1, - "name": "Outrageous Robbery" + "name": "Sage of the Skies" }, { "count": 1, @@ -746,27 +730,27 @@ }, { "count": 1, - "name": "Pyroclasm" + "name": "Outrageous Robbery" }, { "count": 1, - "name": "Riverchurn Monument" + "name": "Day of Black Sun" }, { - "count": 1, - "name": "Spell Snare" + "count": 2, + "name": "Flashfreeze" }, { "count": 2, - "name": "Flashfreeze" + "name": "Thor, God of Thunder" }, { "count": 1, - "name": "Pest Control" + "name": "Riverchurn Monument" }, { - "count": 1, - "name": "Ancient Vendetta" + "count": 3, + "name": "Duress" } ] }, diff --git a/client/src-tauri/Cargo.lock b/client/src-tauri/Cargo.lock index 35d45cf22c..537799a5e7 100644 --- a/client/src-tauri/Cargo.lock +++ b/client/src-tauri/Cargo.lock @@ -2616,7 +2616,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "phase-tauri" -version = "0.49.0" +version = "0.50.0" dependencies = [ "futures-util", "minisign-verify", diff --git a/client/src-tauri/Cargo.toml b/client/src-tauri/Cargo.toml index 777ed770e7..668879b9dd 100644 --- a/client/src-tauri/Cargo.toml +++ b/client/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "phase-tauri" -version = "0.49.0" +version = "0.50.0" edition = "2021" license = "MIT OR Apache-2.0" diff --git a/client/src-tauri/tauri.conf.json b/client/src-tauri/tauri.conf.json index 247dbbe8b9..a80c1dd43f 100644 --- a/client/src-tauri/tauri.conf.json +++ b/client/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-cli/config.schema.json", "productName": "Phase", - "version": "0.49.0", + "version": "0.50.0", "identifier": "rs.phase.app", "build": { "frontendDist": "../bootstrap/dist", diff --git a/client/src/adapter/__tests__/draftPodAdapter.test.ts b/client/src/adapter/__tests__/draftPodAdapter.test.ts index 312cab04b1..04ba6e049d 100644 --- a/client/src/adapter/__tests__/draftPodAdapter.test.ts +++ b/client/src/adapter/__tests__/draftPodAdapter.test.ts @@ -112,6 +112,12 @@ function mockView(status: string): DraftPlayerView { pass_direction: "Left", current_pack: null, pool: [], + pool_groups: { + color_groups: [], + type_groups: [], + cmc_groups: [], + color_counts: { white: 0, blue: 0, black: 0, red: 0, green: 0 }, + }, seats: [], cards_per_pack: 14, pack_count: 3, diff --git a/client/src/adapter/__tests__/server-draft-adapter.test.ts b/client/src/adapter/__tests__/server-draft-adapter.test.ts index e3014665cf..a42de47ff9 100644 --- a/client/src/adapter/__tests__/server-draft-adapter.test.ts +++ b/client/src/adapter/__tests__/server-draft-adapter.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import { EMPTY_DRAFT_POOL_GROUPS } from "../draft-adapter"; import { ServerDraftAdapter } from "../server-draft-adapter"; import { PROTOCOL_VERSION } from "../ws-adapter"; import type { DraftPlayerView } from "../draft-adapter"; @@ -66,6 +67,7 @@ function createMockDraftView(overrides: Partial = {}): DraftPla pass_direction: "Left", current_pack: null, pool: [], + pool_groups: EMPTY_DRAFT_POOL_GROUPS, seats: [], cards_per_pack: 14, pack_count: 3, diff --git a/client/src/adapter/draft-adapter.ts b/client/src/adapter/draft-adapter.ts index 134730ebf7..c949679498 100644 --- a/client/src/adapter/draft-adapter.ts +++ b/client/src/adapter/draft-adapter.ts @@ -14,6 +14,64 @@ export interface DraftCardInstance { type_line: string; } +export type DraftPoolGroupKind = + | "white" + | "blue" + | "black" + | "red" + | "green" + | "multicolor" + | "colorless" + | "creature" + | "instant" + | "sorcery" + | "enchantment" + | "artifact" + | "planeswalker" + | "land" + | "other" + | "mana_value0" + | "mana_value1" + | "mana_value2" + | "mana_value3" + | "mana_value4" + | "mana_value5" + | "mana_value6_plus"; + +export interface DraftPoolEntry { + card: DraftCardInstance; + count: number; +} + +export interface DraftPoolGroup { + kind: DraftPoolGroupKind; + total: number; + cards: DraftPoolEntry[]; +} + +export interface DraftPoolColorCounts { + white: number; + blue: number; + black: number; + red: number; + green: number; +} + +export interface DraftPoolGroups { + color_groups: DraftPoolGroup[]; + type_groups: DraftPoolGroup[]; + cmc_groups: DraftPoolGroup[]; + color_counts: DraftPoolColorCounts; +} + +/** Empty engine-shaped pool data for a lobby before a draft session exists. */ +export const EMPTY_DRAFT_POOL_GROUPS: DraftPoolGroups = { + color_groups: [], + type_groups: [], + cmc_groups: [], + color_counts: { white: 0, blue: 0, black: 0, red: 0, green: 0 }, +}; + // @sync-with: crates/draft-core/src/view.rs export interface SeatPublicView { seat_index: number; @@ -111,6 +169,10 @@ export interface DraftPlayerView { pass_direction: "Left" | "Right"; current_pack: DraftCardInstance[] | null; pool: DraftCardInstance[]; + /** Engine-owned grouping, ordering, and duplicate counts for the pool. */ + pool_groups: DraftPoolGroups; + /** Engine-provided sealed packs in opening order. Absent for draft events. */ + sealed_packs?: DraftCardInstance[][] | null; seats: SeatPublicView[]; cards_per_pack: number; pack_count: number; diff --git a/client/src/adapter/generated/interaction/index.ts b/client/src/adapter/generated/interaction/index.ts index fff6e5b18f..1d77fdb7b3 100644 --- a/client/src/adapter/generated/interaction/index.ts +++ b/client/src/adapter/generated/interaction/index.ts @@ -1,4 +1,4 @@ -// @generated by `cargo run -p engine --features interaction-bindings --bin interaction-bindings -- --write`. +// @generated by `cargo run -p phase-engine --features interaction-bindings --bin interaction-bindings -- --write`. // Do not edit by hand. export type InteractionSessionId = string & { readonly __brand: "InteractionSessionId" }; diff --git a/client/src/adapter/p2p-draft-host.ts b/client/src/adapter/p2p-draft-host.ts index 79c2292475..bce95698d5 100644 --- a/client/src/adapter/p2p-draft-host.ts +++ b/client/src/adapter/p2p-draft-host.ts @@ -12,7 +12,7 @@ import type Peer from "peerjs"; import type { DataConnection } from "peerjs"; -import { DraftAdapter } from "./draft-adapter"; +import { DraftAdapter, EMPTY_DRAFT_POOL_GROUPS } from "./draft-adapter"; import type { DraftPlayerView, MultiplayerSeatDescriptor, PairingView, PoolInput, SeatPublicView } from "./draft-adapter"; import type { PodPolicy, TournamentFormat } from "./draft-adapter"; import { @@ -2015,6 +2015,7 @@ export class P2PDraftHost { pass_direction: "Left", current_pack: null, pool: [], + pool_groups: EMPTY_DRAFT_POOL_GROUPS, seats: this.buildSeatPublicViews(), cards_per_pack: 14, pack_count: 3, diff --git a/client/src/components/draft/DraftSteps.tsx b/client/src/components/draft/DraftSteps.tsx index 5994c83182..018e635cb0 100644 --- a/client/src/components/draft/DraftSteps.tsx +++ b/client/src/components/draft/DraftSteps.tsx @@ -10,6 +10,7 @@ const STEP_KEYS = ["chooseSet", "draft", "buildDeck", "play"] as const; const PHASE_STEP: Record = { setup: 0, drafting: 1, + opening: 1, deckbuilding: 2, launching: 3, playing: 3, diff --git a/client/src/components/draft/PoolPanel.tsx b/client/src/components/draft/PoolPanel.tsx index 46f485fab9..c4f5140b23 100644 --- a/client/src/components/draft/PoolPanel.tsx +++ b/client/src/components/draft/PoolPanel.tsx @@ -1,131 +1,26 @@ -import { useMemo } from "react"; import { useTranslation } from "react-i18next"; import { useDraftStore } from "../../stores/draftStore"; import type { PoolSortMode } from "../../stores/draftStore"; -import type { DraftCardInstance, DraftPlayerView } from "../../adapter/draft-adapter"; +import type { DraftPoolColorCounts, DraftPlayerView } from "../../adapter/draft-adapter"; import type { CardHoverInfo } from "../card/CardPreview"; - -// ── Sorting helpers ───────────────────────────────────────────────────── -// These are display-layer grouping of engine-provided enriched fields -// (DraftCardInstance.colors, cmc, type_line) — not game logic computation. - -function colorGroupKey(card: DraftCardInstance): string { - if (card.colors.length === 0) return "Colorless"; - if (card.colors.length > 1) return "Multicolor"; - return card.colors[0]; -} - -const COLOR_GROUP_ORDER: Record = { - W: 0, U: 1, B: 2, R: 3, G: 4, Multicolor: 5, Colorless: 6, +import { POOL_GROUP_LABEL_KEYS } from "./poolGroupLabels"; + +const EMPTY_COLOR_COUNTS: DraftPoolColorCounts = { + white: 0, + blue: 0, + black: 0, + red: 0, + green: 0, }; -const COLOR_GROUP_LABELS: Record = { - W: "White", U: "Blue", B: "Black", R: "Red", G: "Green", - Multicolor: "Multicolor", Colorless: "Colorless", -}; - -function primaryType(typeLine: string): string { - const lower = typeLine.toLowerCase(); - if (lower.includes("creature")) return "Creature"; - if (lower.includes("instant")) return "Instant"; - if (lower.includes("sorcery")) return "Sorcery"; - if (lower.includes("enchantment")) return "Enchantment"; - if (lower.includes("artifact")) return "Artifact"; - if (lower.includes("planeswalker")) return "Planeswalker"; - if (lower.includes("land")) return "Land"; - return "Other"; -} - -const TYPE_ORDER: Record = { - Creature: 0, Instant: 1, Sorcery: 2, Enchantment: 3, - Artifact: 4, Planeswalker: 5, Land: 6, Other: 7, -}; - -interface PoolEntry { - card: DraftCardInstance; - count: number; -} - -interface CardGroup { - label: string; - cards: PoolEntry[]; -} - -function dedup(cards: DraftCardInstance[]): PoolEntry[] { - const map = new Map(); - for (const card of cards) { - const existing = map.get(card.name); - if (existing) { - existing.count++; - } else { - map.set(card.name, { card, count: 1 }); - } - } - return [...map.values()]; -} - -function sortWithinGroup(cards: DraftCardInstance[]): PoolEntry[] { - const sorted = [...cards].sort((a, b) => a.cmc - b.cmc || a.name.localeCompare(b.name)); - return dedup(sorted); -} - -function groupByColor(pool: DraftCardInstance[]): CardGroup[] { - const groups = new Map(); - for (const card of pool) { - const key = colorGroupKey(card); - const list = groups.get(key) ?? []; - list.push(card); - groups.set(key, list); - } - return [...groups.entries()] - .sort(([a], [b]) => (COLOR_GROUP_ORDER[a] ?? 99) - (COLOR_GROUP_ORDER[b] ?? 99)) - .map(([key, cards]) => ({ - label: COLOR_GROUP_LABELS[key] ?? key, - cards: sortWithinGroup(cards), - })); -} - -function groupByType(pool: DraftCardInstance[]): CardGroup[] { - const groups = new Map(); - for (const card of pool) { - const key = primaryType(card.type_line); - const list = groups.get(key) ?? []; - list.push(card); - groups.set(key, list); - } - return [...groups.entries()] - .sort(([a], [b]) => (TYPE_ORDER[a] ?? 99) - (TYPE_ORDER[b] ?? 99)) - .map(([key, cards]) => ({ - label: key, - cards: sortWithinGroup(cards), - })); -} - -function groupByCmc(pool: DraftCardInstance[]): CardGroup[] { - const groups = new Map(); - for (const card of pool) { - const key = card.cmc >= 6 ? "6+" : String(card.cmc); - const list = groups.get(key) ?? []; - list.push(card); - groups.set(key, list); - } - const cmcOrder = ["0", "1", "2", "3", "4", "5", "6+"]; - return cmcOrder - .filter((k) => groups.has(k)) - .map((key) => ({ - label: `${key} CMC`, - cards: dedup([...groups.get(key)!].sort((a, b) => a.name.localeCompare(b.name))), - })); -} - -function groupPool(pool: DraftCardInstance[], mode: PoolSortMode): CardGroup[] { - switch (mode) { - case "color": return groupByColor(pool); - case "type": return groupByType(pool); - case "cmc": return groupByCmc(pool); - } -} +const COLOR_COUNT_KEYS = { + W: "white", + U: "blue", + B: "black", + R: "red", + G: "green", +} as const; // ── Rarity badge ──────────────────────────────────────────────────────── @@ -190,25 +85,15 @@ export function PoolPanel({ onCardHover, view: viewOverride }: PoolPanelProps = const togglePoolPanel = useDraftStore((s) => s.togglePoolPanel); const view = viewOverride !== undefined ? viewOverride : quickView; - const pool = useMemo(() => view?.pool ?? [], [view?.pool]); - - const groups = useMemo( - () => groupPool(pool, poolSortMode), - [pool, poolSortMode], - ); - - // WUBRG pool tally — a multicolor card counts toward each of its colors - // (standard draft-tracker convention), so the strip reads as "how deep am I - // in each color" rather than a mono-only count. - const colorCounts = useMemo(() => { - const counts: Record = { W: 0, U: 0, B: 0, R: 0, G: 0 }; - for (const card of pool) { - for (const color of card.colors) { - if (color in counts) counts[color]++; - } - } - return counts; - }, [pool]); + const pool = view?.pool ?? []; + const groups = view + ? poolSortMode === "color" + ? view.pool_groups.color_groups + : poolSortMode === "type" + ? view.pool_groups.type_groups + : view.pool_groups.cmc_groups + : []; + const colorCounts = view?.pool_groups.color_counts ?? EMPTY_COLOR_COUNTS; return (
@@ -235,8 +120,8 @@ export function PoolPanel({ onCardHover, view: viewOverride }: PoolPanelProps = {(["W", "U", "B", "R", "G"] as const).map((c) => (
- - {colorCounts[c]} + + {colorCounts[COLOR_COUNT_KEYS[c]]}
))} @@ -262,9 +147,9 @@ export function PoolPanel({ onCardHover, view: viewOverride }: PoolPanelProps = {/* Card groups */}
{groups.map((group) => ( -
+
- {group.label} ({group.cards.length}) + {t(POOL_GROUP_LABEL_KEYS[group.kind])} ({group.total})
{group.cards.map(({ card, count }) => ( diff --git a/client/src/components/draft/SealedPackOpening.tsx b/client/src/components/draft/SealedPackOpening.tsx new file mode 100644 index 0000000000..8157e9e2fb --- /dev/null +++ b/client/src/components/draft/SealedPackOpening.tsx @@ -0,0 +1,220 @@ +import { useEffect, useState } from "react"; +import { AnimatePresence, motion, useReducedMotion } from "framer-motion"; +import { useTranslation } from "react-i18next"; + +import type { DraftCardInstance, DraftPoolGroup, DraftPlayerView } from "../../adapter/draft-adapter"; +import { useCardImage } from "../../hooks/useCardImage"; +import { menuButtonClass } from "../menu/buttonStyles"; +import { POOL_GROUP_LABEL_KEYS } from "./poolGroupLabels"; + +interface SealedPackOpeningProps { + view: DraftPlayerView; + onComplete: () => void; +} + +function PullCard({ card, index }: { card: DraftCardInstance; index: number }) { + const { src, isLoading } = useCardImage(card.name, { + size: "normal", + sourcePrinting: { setCode: card.set_code, collectorNumber: card.collector_number }, + }); + const reduceMotion = useReducedMotion(); + + return ( + + {isLoading || !src ? ( +
+ {card.name} +
+ ) : ( + {card.name} + )} +
+ {card.name} +
+
+ ); +} + +function PackBack({ onOpen, packNumber, packCount }: { + onOpen: () => void; + packNumber: number; + packCount: number; +}) { + const { t } = useTranslation("draft"); + const reduceMotion = useReducedMotion(); + + return ( + +

+ {t("sealedOpening.packProgress", { current: packNumber, total: packCount })} +

+ + + + + ✦ + + + +
+ ); +} + +function OpenedPack({ cards, packNumber, packCount, onNext }: { + cards: DraftCardInstance[]; + packNumber: number; + packCount: number; + onNext: () => void; +}) { + const { t } = useTranslation("draft"); + + return ( + +
+
+

+ {t("sealedOpening.packProgress", { current: packNumber, total: packCount })} +

+

{t("sealedOpening.pulls")}

+
+ {t("pack.cardsInPack", { count: cards.length })} +
+
+ {cards.map((card, index) => )} +
+
+ +
+
+ ); +} + +function SealedPoolReview({ groups, poolSize, onComplete }: { + groups: DraftPoolGroup[]; + poolSize: number; + onComplete: () => void; +}) { + const { t } = useTranslation("draft"); + + return ( + +
+

{t("sealedOpening.poolTitle")}

+

{t("sealedOpening.poolSubtitle", { count: poolSize })}

+
+ {groups.map((group) => ( +
+

+ {t(POOL_GROUP_LABEL_KEYS[group.kind])} ({group.total}) +

+
+ {group.cards.map(({ card, count }, index) => ( +
+ + {count > 1 && ( + + {count} + + )} +
+ ))} +
+
+ ))} +
+ +
+
+ ); +} + +/** Displays the engine-provided sealed packs one at a time before deckbuilding. */ +export function SealedPackOpening({ view, onComplete }: SealedPackOpeningProps) { + const { t } = useTranslation("draft"); + const packs = view.sealed_packs ?? []; + const [packIndex, setPackIndex] = useState(0); + const [opened, setOpened] = useState(false); + + useEffect(() => { + setOpened(false); + }, [packIndex]); + + if (packs.length === 0) return null; + + const packNumber = packIndex + 1; + const showReview = packIndex >= packs.length; + const advance = () => setPackIndex((current) => current + 1); + + return ( +
+ {!showReview && ( +
+

{t("sealedOpening.title")}

+

{t("sealedOpening.subtitle")}

+
+ )} + + {showReview ? ( + + ) : opened ? ( + + ) : ( + setOpened(true)} + /> + )} + +
+ ); +} diff --git a/client/src/components/draft/__tests__/LimitedDeckBuilder.test.tsx b/client/src/components/draft/__tests__/LimitedDeckBuilder.test.tsx index 938368b17f..2120fc78ec 100644 --- a/client/src/components/draft/__tests__/LimitedDeckBuilder.test.tsx +++ b/client/src/components/draft/__tests__/LimitedDeckBuilder.test.tsx @@ -48,6 +48,12 @@ const TEST_VIEW: BuilderView = { type_line: "Creature - Drake", }, ], + pool_groups: { + color_groups: [], + type_groups: [], + cmc_groups: [], + color_counts: { white: 0, blue: 1, black: 0, red: 0, green: 0 }, + }, seats: [], cards_per_pack: 14, pack_count: 3, diff --git a/client/src/components/draft/__tests__/PackDisplay.pod.test.tsx b/client/src/components/draft/__tests__/PackDisplay.pod.test.tsx index 5c3957301c..d6b7efbd17 100644 --- a/client/src/components/draft/__tests__/PackDisplay.pod.test.tsx +++ b/client/src/components/draft/__tests__/PackDisplay.pod.test.tsx @@ -39,6 +39,12 @@ const view: DraftPlayerView = { }, ], pool: [], + pool_groups: { + color_groups: [], + type_groups: [], + cmc_groups: [], + color_counts: { white: 0, blue: 0, black: 0, red: 0, green: 0 }, + }, seats: [], cards_per_pack: 14, pack_count: 3, diff --git a/client/src/components/draft/__tests__/SealedPackOpening.test.tsx b/client/src/components/draft/__tests__/SealedPackOpening.test.tsx new file mode 100644 index 0000000000..9ec752417b --- /dev/null +++ b/client/src/components/draft/__tests__/SealedPackOpening.test.tsx @@ -0,0 +1,109 @@ +import { describe, expect, it, vi } from "vitest"; +import { fireEvent, render, screen } from "@testing-library/react"; + +import { SealedPackOpening } from "../SealedPackOpening"; +import type { DraftPlayerView } from "../../../adapter/draft-adapter"; + +vi.mock("../../../hooks/useCardImage", () => ({ + useCardImage: () => ({ src: null, isLoading: false }), +})); + +const VIEW: DraftPlayerView = { + status: "Deckbuilding", + kind: "Sealed", + current_pack_number: 0, + pick_number: 0, + pass_direction: "Left", + current_pack: null, + pool: [ + { + instance_id: "creature", + name: "Silvercoat Lion", + set_code: "m19", + collector_number: "31", + rarity: "common", + colors: ["W"], + cmc: 2, + type_line: "Creature — Cat", + }, + { + instance_id: "instant", + name: "Shock", + set_code: "m19", + collector_number: "156", + rarity: "common", + colors: ["R"], + cmc: 1, + type_line: "Instant", + }, + ], + pool_groups: { + color_groups: [], + type_groups: [ + { kind: "creature", total: 1, cards: [{ card: { + instance_id: "creature", + name: "Silvercoat Lion", + set_code: "m19", + collector_number: "31", + rarity: "common", + colors: ["W"], + cmc: 2, + type_line: "Creature — Cat", + }, count: 1 }] }, + { kind: "instant", total: 1, cards: [{ card: { + instance_id: "instant", + name: "Shock", + set_code: "m19", + collector_number: "156", + rarity: "common", + colors: ["R"], + cmc: 1, + type_line: "Instant", + }, count: 1 }] }, + ], + cmc_groups: [], + color_counts: { white: 1, blue: 0, black: 0, red: 1, green: 0 }, + }, + sealed_packs: [], + seats: [], + cards_per_pack: 1, + pack_count: 2, + min_deck_size: 40, + addable_cards: ["Plains"], + timer_remaining_ms: null, + standings: [], + current_round: 0, + tournament_format: "Swiss", + pod_policy: "Competitive", + pairings: [], + match_config: { match_type: "Bo1" }, +}; + +describe("SealedPackOpening", () => { + it("reveals each engine-provided pack before showing the type-grouped pool", async () => { + const onComplete = vi.fn(); + render( + , + ); + + expect(screen.getByText("Pack 1 of 2")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Open pack" })); + expect(await screen.findAllByText("Silvercoat Lion")).toHaveLength(2); + + fireEvent.click(screen.getByRole("button", { name: "Next pack" })); + expect(await screen.findByText("Pack 2 of 2")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Open pack" })); + expect(await screen.findAllByText("Shock")).toHaveLength(2); + + fireEvent.click(screen.getByRole("button", { name: "View your pool" })); + expect(await screen.findByRole("heading", { name: "Your sealed pool" })).toBeInTheDocument(); + expect(screen.getByText("Creature (1)")).toBeInTheDocument(); + expect(screen.getByText("Instant (1)")).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Build deck" })); + expect(onComplete).toHaveBeenCalledOnce(); + }); +}); diff --git a/client/src/components/draft/poolGroupLabels.ts b/client/src/components/draft/poolGroupLabels.ts new file mode 100644 index 0000000000..c5c4be670d --- /dev/null +++ b/client/src/components/draft/poolGroupLabels.ts @@ -0,0 +1,27 @@ +import type { DraftPoolGroupKind } from "../../adapter/draft-adapter"; + +/** Translation keys for the engine-defined limited-pool group categories. */ +export const POOL_GROUP_LABEL_KEYS: Record = { + white: "pool.groups.white", + blue: "pool.groups.blue", + black: "pool.groups.black", + red: "pool.groups.red", + green: "pool.groups.green", + multicolor: "pool.groups.multicolor", + colorless: "pool.groups.colorless", + creature: "pool.groups.creature", + instant: "pool.groups.instant", + sorcery: "pool.groups.sorcery", + enchantment: "pool.groups.enchantment", + artifact: "pool.groups.artifact", + planeswalker: "pool.groups.planeswalker", + land: "pool.groups.land", + other: "pool.groups.other", + mana_value0: "pool.groups.manaValue0", + mana_value1: "pool.groups.manaValue1", + mana_value2: "pool.groups.manaValue2", + mana_value3: "pool.groups.manaValue3", + mana_value4: "pool.groups.manaValue4", + mana_value5: "pool.groups.manaValue5", + mana_value6_plus: "pool.groups.manaValue6Plus", +}; diff --git a/client/src/i18n/locales/de/draft.json b/client/src/i18n/locales/de/draft.json index a75048b85c..ab871af69e 100644 --- a/client/src/i18n/locales/de/draft.json +++ b/client/src/i18n/locales/de/draft.json @@ -82,12 +82,49 @@ "picking": "Wird ausgewählt…", "autoPick": "Auto-Pick" }, + "sealedOpening": { + "title": "Öffne deinen Sealed-Pool", + "subtitle": "Öffne sechs Booster, einen nach dem anderen.", + "packProgress": "Booster {{current}} von {{total}}", + "openPack": "Booster öffnen", + "openPackArt": "Booster-Artwork öffnen", + "pulls": "Deine Karten", + "nextPack": "Nächster Booster", + "viewPool": "Pool ansehen", + "poolTitle": "Dein Sealed-Pool", + "poolSubtitle": "Alle {{count}} Karten, nach Typ gruppiert.", + "buildDeck": "Deck bauen" + }, "pool": { "cardsDrafted": "{{count}} Karten gedraftet", "empty": "Noch keine Karten gedraftet", "sortColor": "Farbe", "sortType": "Typ", - "sortCmc": "CMC" + "sortCmc": "CMC", + "groups": { + "white": "Weiß", + "blue": "Blau", + "black": "Schwarz", + "red": "Rot", + "green": "Grün", + "multicolor": "Mehrfarbig", + "colorless": "Farblos", + "creature": "Kreatur", + "instant": "Spontanzauber", + "sorcery": "Hexerei", + "enchantment": "Verzauberung", + "artifact": "Artefakt", + "planeswalker": "Planeswalker", + "land": "Land", + "other": "Andere", + "manaValue0": "0 CMC", + "manaValue1": "1 CMC", + "manaValue2": "2 CMC", + "manaValue3": "3 CMC", + "manaValue4": "4 CMC", + "manaValue5": "5 CMC", + "manaValue6Plus": "6+ CMC" + } }, "standings": { "title": "Tabelle — Runde {{round}}", @@ -129,6 +166,10 @@ "title": "Quick Draft", "description": "Drafte 3 Booster mit 7 KI-Draftern, baue ein 40-Karten-Deck und spiele dann eine Bo1-Partie gegen einen von ihnen." }, + "sealed": { + "title": "Sealed", + "description": "Öffne sechs Booster, sieh dir den vollständigen Pool an und baue dann ein 40-Karten-Deck für eine Bo1-Partie." + }, "cubeDraft": { "title": "Cube Draft", "description": "Füge eine Cube-Liste ein oder lade sie, passe Booster- und Deckregeln an und drafte dann schnell gegen KI-Bots." @@ -159,6 +200,7 @@ }, "quickPhase": { "drafting": "Draften", + "opening": "Booster öffnen", "deckbuilding": "Deckbau", "matchRecord": "Partie {{number}} — {{wins}}S-{{losses}}N", "record": "{{wins}}S-{{losses}}N", diff --git a/client/src/i18n/locales/en/draft.json b/client/src/i18n/locales/en/draft.json index 15adb96b4e..58c87cc82a 100644 --- a/client/src/i18n/locales/en/draft.json +++ b/client/src/i18n/locales/en/draft.json @@ -82,12 +82,49 @@ "picking": "Picking…", "autoPick": "Auto-pick" }, + "sealedOpening": { + "title": "Open your sealed pool", + "subtitle": "Open six packs, one at a time.", + "packProgress": "Pack {{current}} of {{total}}", + "openPack": "Open pack", + "openPackArt": "Open pack artwork", + "pulls": "Your pulls", + "nextPack": "Next pack", + "viewPool": "View your pool", + "poolTitle": "Your sealed pool", + "poolSubtitle": "All {{count}} cards, grouped by type.", + "buildDeck": "Build deck" + }, "pool": { "cardsDrafted": "{{count}} cards drafted", "empty": "No cards drafted yet", "sortColor": "Color", "sortType": "Type", - "sortCmc": "CMC" + "sortCmc": "CMC", + "groups": { + "white": "White", + "blue": "Blue", + "black": "Black", + "red": "Red", + "green": "Green", + "multicolor": "Multicolor", + "colorless": "Colorless", + "creature": "Creature", + "instant": "Instant", + "sorcery": "Sorcery", + "enchantment": "Enchantment", + "artifact": "Artifact", + "planeswalker": "Planeswalker", + "land": "Land", + "other": "Other", + "manaValue0": "0 CMC", + "manaValue1": "1 CMC", + "manaValue2": "2 CMC", + "manaValue3": "3 CMC", + "manaValue4": "4 CMC", + "manaValue5": "5 CMC", + "manaValue6Plus": "6+ CMC" + } }, "standings": { "title": "Standings — Round {{round}}", @@ -129,6 +166,10 @@ "title": "Quick Draft", "description": "Draft 3 packs with 7 AI drafters, build a 40-card deck, then play a Bo1 match against one of them." }, + "sealed": { + "title": "Sealed", + "description": "Open six packs, review the full pool, then build a 40-card deck for a Bo1 match." + }, "cubeDraft": { "title": "Cube Draft", "description": "Paste or load a cube list, tune pack and deck rules, then quick draft against AI bots." @@ -159,6 +200,7 @@ }, "quickPhase": { "drafting": "Drafting", + "opening": "Opening Packs", "deckbuilding": "Deck Building", "matchRecord": "Match {{number}} — {{wins}}W-{{losses}}L", "record": "{{wins}}W-{{losses}}L", diff --git a/client/src/i18n/locales/es/draft.json b/client/src/i18n/locales/es/draft.json index 4b6f23707a..dc31c30b4c 100644 --- a/client/src/i18n/locales/es/draft.json +++ b/client/src/i18n/locales/es/draft.json @@ -82,12 +82,49 @@ "picking": "Eligiendo…", "autoPick": "Elección automática" }, + "sealedOpening": { + "title": "Abre tu pool de sellado", + "subtitle": "Abre seis sobres, uno a la vez.", + "packProgress": "Sobre {{current}} de {{total}}", + "openPack": "Abrir sobre", + "openPackArt": "Abrir ilustración del sobre", + "pulls": "Tus cartas", + "nextPack": "Siguiente sobre", + "viewPool": "Ver tu pool", + "poolTitle": "Tu pool de sellado", + "poolSubtitle": "Las {{count}} cartas, agrupadas por tipo.", + "buildDeck": "Construir mazo" + }, "pool": { "cardsDrafted": "{{count}} cartas drafteadas", "empty": "Aún no se ha drafteado ninguna carta", "sortColor": "Color", "sortType": "Tipo", - "sortCmc": "CMC" + "sortCmc": "CMC", + "groups": { + "white": "Blanco", + "blue": "Azul", + "black": "Negro", + "red": "Rojo", + "green": "Verde", + "multicolor": "Multicolor", + "colorless": "Incoloro", + "creature": "Criatura", + "instant": "Instantáneo", + "sorcery": "Conjuro", + "enchantment": "Encantamiento", + "artifact": "Artefacto", + "planeswalker": "Planeswalker", + "land": "Tierra", + "other": "Otro", + "manaValue0": "0 CMC", + "manaValue1": "1 CMC", + "manaValue2": "2 CMC", + "manaValue3": "3 CMC", + "manaValue4": "4 CMC", + "manaValue5": "5 CMC", + "manaValue6Plus": "6+ CMC" + } }, "standings": { "title": "Clasificación — Ronda {{round}}", @@ -129,6 +166,10 @@ "title": "Draft Rápido", "description": "Draftea 3 sobres con 7 drafters de IA, construye un mazo de 40 cartas y luego juega una partida Bo1 contra uno de ellos." }, + "sealed": { + "title": "Sealed", + "description": "Abre seis sobres, revisa el pool completo y luego construye un mazo de 40 cartas para una partida al mejor de uno." + }, "cubeDraft": { "title": "Cube Draft", "description": "Pega o carga una lista de cube, ajusta las reglas de sobres y mazo, y luego draftea rápido contra bots de IA." @@ -159,6 +200,7 @@ }, "quickPhase": { "drafting": "Drafteando", + "opening": "Abriendo sobres", "deckbuilding": "Construcción de mazo", "matchRecord": "Partida {{number}} — {{wins}}V-{{losses}}D", "record": "{{wins}}V-{{losses}}D", diff --git a/client/src/i18n/locales/fr/draft.json b/client/src/i18n/locales/fr/draft.json index 2df4798af2..456a9fc74e 100644 --- a/client/src/i18n/locales/fr/draft.json +++ b/client/src/i18n/locales/fr/draft.json @@ -82,12 +82,49 @@ "picking": "Sélection…", "autoPick": "Sélection auto" }, + "sealedOpening": { + "title": "Ouvrez votre pool scellé", + "subtitle": "Ouvrez six boosters, un à la fois.", + "packProgress": "Booster {{current}} sur {{total}}", + "openPack": "Ouvrir le booster", + "openPackArt": "Ouvrir l’illustration du booster", + "pulls": "Vos cartes", + "nextPack": "Booster suivant", + "viewPool": "Voir votre pool", + "poolTitle": "Votre pool scellé", + "poolSubtitle": "Les {{count}} cartes, regroupées par type.", + "buildDeck": "Construire le deck" + }, "pool": { "cardsDrafted": "{{count}} cartes draftées", "empty": "Aucune carte draftée pour l'instant", "sortColor": "Couleur", "sortType": "Type", - "sortCmc": "CMC" + "sortCmc": "CMC", + "groups": { + "white": "Blanc", + "blue": "Bleu", + "black": "Noir", + "red": "Rouge", + "green": "Vert", + "multicolor": "Multicolore", + "colorless": "Incolore", + "creature": "Créature", + "instant": "Éphémère", + "sorcery": "Rituel", + "enchantment": "Enchantement", + "artifact": "Artefact", + "planeswalker": "Planeswalker", + "land": "Terrain", + "other": "Autre", + "manaValue0": "0 CMC", + "manaValue1": "1 CMC", + "manaValue2": "2 CMC", + "manaValue3": "3 CMC", + "manaValue4": "4 CMC", + "manaValue5": "5 CMC", + "manaValue6Plus": "6+ CMC" + } }, "standings": { "title": "Classement — Manche {{round}}", @@ -129,6 +166,10 @@ "title": "Quick Draft", "description": "Draftez 3 boosters avec 7 drafteurs IA, construisez un deck de 40 cartes, puis jouez un match Bo1 contre l'un d'eux." }, + "sealed": { + "title": "Sealed", + "description": "Ouvrez six boosters, consultez le pool complet, puis construisez un deck de 40 cartes pour un match en une partie." + }, "cubeDraft": { "title": "Cube Draft", "description": "Collez ou chargez une liste de cube, ajustez les règles de booster et de deck, puis faites un quick draft contre des bots IA." @@ -159,6 +200,7 @@ }, "quickPhase": { "drafting": "Draft", + "opening": "Ouverture des boosters", "deckbuilding": "Construction de deck", "matchRecord": "Match {{number}} — {{wins}}V-{{losses}}D", "record": "{{wins}}V-{{losses}}D", diff --git a/client/src/i18n/locales/it/draft.json b/client/src/i18n/locales/it/draft.json index f67fa1a48a..b70e7e0f83 100644 --- a/client/src/i18n/locales/it/draft.json +++ b/client/src/i18n/locales/it/draft.json @@ -82,12 +82,49 @@ "picking": "Scelta…", "autoPick": "Scelta automatica" }, + "sealedOpening": { + "title": "Apri il tuo pool Sealed", + "subtitle": "Apri sei buste, una alla volta.", + "packProgress": "Busta {{current}} di {{total}}", + "openPack": "Apri busta", + "openPackArt": "Apri l’illustrazione della busta", + "pulls": "Le tue carte", + "nextPack": "Busta successiva", + "viewPool": "Vedi il tuo pool", + "poolTitle": "Il tuo pool Sealed", + "poolSubtitle": "Tutte le {{count}} carte, raggruppate per tipo.", + "buildDeck": "Costruisci il mazzo" + }, "pool": { "cardsDrafted": "{{count}} carte draftate", "empty": "Nessuna carta ancora draftata", "sortColor": "Colore", "sortType": "Tipo", - "sortCmc": "CMC" + "sortCmc": "CMC", + "groups": { + "white": "Bianco", + "blue": "Blu", + "black": "Nero", + "red": "Rosso", + "green": "Verde", + "multicolor": "Multicolore", + "colorless": "Incolore", + "creature": "Creatura", + "instant": "Istantaneo", + "sorcery": "Stregoneria", + "enchantment": "Incantesimo", + "artifact": "Artefatto", + "planeswalker": "Planeswalker", + "land": "Terra", + "other": "Altro", + "manaValue0": "0 CMC", + "manaValue1": "1 CMC", + "manaValue2": "2 CMC", + "manaValue3": "3 CMC", + "manaValue4": "4 CMC", + "manaValue5": "5 CMC", + "manaValue6Plus": "6+ CMC" + } }, "standings": { "title": "Classifica — Round {{round}}", @@ -129,6 +166,10 @@ "title": "Quick Draft", "description": "Drafta 3 buste con 7 draftatori IA, costruisci un mazzo da 40 carte, poi gioca una partita Bo1 contro uno di loro." }, + "sealed": { + "title": "Sealed", + "description": "Apri sei buste, esamina il pool completo e poi costruisci un mazzo da 40 carte per una partita Bo1." + }, "cubeDraft": { "title": "Cube Draft", "description": "Incolla o carica una lista cube, regola le regole di busta e mazzo, poi fai un quick draft contro i bot IA." @@ -159,6 +200,7 @@ }, "quickPhase": { "drafting": "Draft", + "opening": "Apertura buste", "deckbuilding": "Costruzione mazzo", "matchRecord": "Partita {{number}} — {{wins}}V-{{losses}}S", "record": "{{wins}}V-{{losses}}S", diff --git a/client/src/i18n/locales/pl/draft.json b/client/src/i18n/locales/pl/draft.json index 31212af48a..5fc02033aa 100644 --- a/client/src/i18n/locales/pl/draft.json +++ b/client/src/i18n/locales/pl/draft.json @@ -82,12 +82,49 @@ "picking": "Wybieranie…", "autoPick": "Wybór automatyczny" }, + "sealedOpening": { + "title": "Otwórz swoją pulę Sealed", + "subtitle": "Otwórz sześć boosterów, po jednym na raz.", + "packProgress": "Booster {{current}} z {{total}}", + "openPack": "Otwórz booster", + "openPackArt": "Otwórz grafikę boostera", + "pulls": "Twoje karty", + "nextPack": "Następny booster", + "viewPool": "Zobacz swoją pulę", + "poolTitle": "Twoja pula Sealed", + "poolSubtitle": "Wszystkie {{count}} karty, pogrupowane według typu.", + "buildDeck": "Zbuduj talię" + }, "pool": { "cardsDrafted": "{{count}} wybranych kart", "empty": "Nie wybrano jeszcze żadnych kart", "sortColor": "Kolor", "sortType": "Typ", - "sortCmc": "CMC" + "sortCmc": "CMC", + "groups": { + "white": "Biały", + "blue": "Niebieski", + "black": "Czarny", + "red": "Czerwony", + "green": "Zielony", + "multicolor": "Wielokolorowy", + "colorless": "Bezkolorowy", + "creature": "Stworzenie", + "instant": "Instant", + "sorcery": "Sorcery", + "enchantment": "Enchantment", + "artifact": "Artefakt", + "planeswalker": "Planeswalker", + "land": "Ląd", + "other": "Inne", + "manaValue0": "0 CMC", + "manaValue1": "1 CMC", + "manaValue2": "2 CMC", + "manaValue3": "3 CMC", + "manaValue4": "4 CMC", + "manaValue5": "5 CMC", + "manaValue6Plus": "6+ CMC" + } }, "standings": { "title": "Klasyfikacja — Runda {{round}}", @@ -129,6 +166,10 @@ "title": "Szybki draft", "description": "Zdraftuj 3 boostery z 7 botami, zbuduj talię z 40 kart, a potem rozegraj mecz Bo1 przeciwko jednemu z nich." }, + "sealed": { + "title": "Sealed", + "description": "Otwórz sześć boosterów, przejrzyj całą pulę, a potem zbuduj talię z 40 kart na mecz Bo1." + }, "cubeDraft": { "title": "Draft kostki", "description": "Wklej lub wczytaj listę kostki, dostosuj zasady boosterów i talii, a potem zdraftuj szybko przeciwko botom." @@ -159,6 +200,7 @@ }, "quickPhase": { "drafting": "Draftowanie", + "opening": "Otwieranie boosterów", "deckbuilding": "Budowanie talii", "matchRecord": "Mecz {{number}} — {{wins}}W-{{losses}}P", "record": "{{wins}}W-{{losses}}P", diff --git a/client/src/i18n/locales/pt/draft.json b/client/src/i18n/locales/pt/draft.json index ff2a1c3602..96672f5996 100644 --- a/client/src/i18n/locales/pt/draft.json +++ b/client/src/i18n/locales/pt/draft.json @@ -82,12 +82,49 @@ "picking": "Escolhendo…", "autoPick": "Escolha automática" }, + "sealedOpening": { + "title": "Abra seu pool de Selado", + "subtitle": "Abra seis boosters, um de cada vez.", + "packProgress": "Booster {{current}} de {{total}}", + "openPack": "Abrir booster", + "openPackArt": "Abrir arte do booster", + "pulls": "Suas cartas", + "nextPack": "Próximo booster", + "viewPool": "Ver seu pool", + "poolTitle": "Seu pool de Selado", + "poolSubtitle": "Todas as {{count}} cartas, agrupadas por tipo.", + "buildDeck": "Montar deck" + }, "pool": { "cardsDrafted": "{{count}} cartas draftadas", "empty": "Nenhuma carta draftada ainda", "sortColor": "Cor", "sortType": "Tipo", - "sortCmc": "CMC" + "sortCmc": "CMC", + "groups": { + "white": "Branco", + "blue": "Azul", + "black": "Preto", + "red": "Vermelho", + "green": "Verde", + "multicolor": "Multicolorido", + "colorless": "Incolor", + "creature": "Criatura", + "instant": "Mágica instantânea", + "sorcery": "Feitiço", + "enchantment": "Encantamento", + "artifact": "Artefato", + "planeswalker": "Planeswalker", + "land": "Terreno", + "other": "Outro", + "manaValue0": "0 CMC", + "manaValue1": "1 CMC", + "manaValue2": "2 CMC", + "manaValue3": "3 CMC", + "manaValue4": "4 CMC", + "manaValue5": "5 CMC", + "manaValue6Plus": "6+ CMC" + } }, "standings": { "title": "Classificação — Rodada {{round}}", @@ -129,6 +166,10 @@ "title": "Quick Draft", "description": "Drafte 3 boosters com 7 draftadores de IA, monte um deck de 40 cartas e jogue uma partida Bo1 contra um deles." }, + "sealed": { + "title": "Sealed", + "description": "Abra seis boosters, revise o pool completo e depois monte um deck de 40 cartas para uma partida Bo1." + }, "cubeDraft": { "title": "Cube Draft", "description": "Cole ou carregue uma lista de Cube, ajuste as regras de booster e deck e depois faça um quick draft contra bots de IA." @@ -159,6 +200,7 @@ }, "quickPhase": { "drafting": "Draftando", + "opening": "Abrindo boosters", "deckbuilding": "Montagem de Deck", "matchRecord": "Partida {{number}} — {{wins}}V-{{losses}}D", "record": "{{wins}}V-{{losses}}D", diff --git a/client/src/network/__tests__/draftProtocol.test.ts b/client/src/network/__tests__/draftProtocol.test.ts index d65794b2a5..27ef27db6e 100644 --- a/client/src/network/__tests__/draftProtocol.test.ts +++ b/client/src/network/__tests__/draftProtocol.test.ts @@ -10,8 +10,8 @@ import type { DraftP2PMessage } from "../draftProtocol"; describe("draftProtocol", () => { describe("DRAFT_PROTOCOL_VERSION", () => { - it("is version 8", () => { - expect(DRAFT_PROTOCOL_VERSION).toBe(8); + it("is version 9", () => { + expect(DRAFT_PROTOCOL_VERSION).toBe(9); }); }); @@ -108,8 +108,8 @@ describe("draftProtocol", () => { it("round-trips a large message (gzip path)", async () => { // Build a message large enough to trigger compression const longView = { - status: "Drafting", - kind: "Premier", + status: "Deckbuilding", + kind: "Sealed", current_pack_number: 1, pick_number: 3, pass_direction: "Left", @@ -123,7 +123,119 @@ describe("draftProtocol", () => { cmc: i % 7, type_line: "Creature - Human Wizard", })), - pool: [], + pool: [ + { + instance_id: "pack-1-card-1", + name: "First Pull", + set_code: "TST", + collector_number: "101", + rarity: "common", + colors: ["W"], + cmc: 1, + type_line: "Creature — Test", + }, + { + instance_id: "pack-2-card-1", + name: "Second Pull", + set_code: "TST", + collector_number: "102", + rarity: "uncommon", + colors: ["U"], + cmc: 2, + type_line: "Instant", + }, + ], + pool_groups: { + color_groups: [ + { kind: "white", total: 1, cards: [{ card: { + instance_id: "pack-1-card-1", + name: "First Pull", + set_code: "TST", + collector_number: "101", + rarity: "common", + colors: ["W"], + cmc: 1, + type_line: "Creature — Test", + }, count: 1 }] }, + { kind: "blue", total: 1, cards: [{ card: { + instance_id: "pack-2-card-1", + name: "Second Pull", + set_code: "TST", + collector_number: "102", + rarity: "uncommon", + colors: ["U"], + cmc: 2, + type_line: "Instant", + }, count: 1 }] }, + ], + type_groups: [ + { kind: "creature", total: 1, cards: [{ card: { + instance_id: "pack-1-card-1", + name: "First Pull", + set_code: "TST", + collector_number: "101", + rarity: "common", + colors: ["W"], + cmc: 1, + type_line: "Creature — Test", + }, count: 1 }] }, + { kind: "instant", total: 1, cards: [{ card: { + instance_id: "pack-2-card-1", + name: "Second Pull", + set_code: "TST", + collector_number: "102", + rarity: "uncommon", + colors: ["U"], + cmc: 2, + type_line: "Instant", + }, count: 1 }] }, + ], + cmc_groups: [ + { kind: "mana_value1", total: 1, cards: [{ card: { + instance_id: "pack-1-card-1", + name: "First Pull", + set_code: "TST", + collector_number: "101", + rarity: "common", + colors: ["W"], + cmc: 1, + type_line: "Creature — Test", + }, count: 1 }] }, + { kind: "mana_value2", total: 1, cards: [{ card: { + instance_id: "pack-2-card-1", + name: "Second Pull", + set_code: "TST", + collector_number: "102", + rarity: "uncommon", + colors: ["U"], + cmc: 2, + type_line: "Instant", + }, count: 1 }] }, + ], + color_counts: { white: 1, blue: 1, black: 0, red: 0, green: 0 }, + }, + sealed_packs: [ + [{ + instance_id: "pack-1-card-1", + name: "First Pull", + set_code: "TST", + collector_number: "101", + rarity: "common", + colors: ["W"], + cmc: 1, + type_line: "Creature — Test", + }], + [{ + instance_id: "pack-2-card-1", + name: "Second Pull", + set_code: "TST", + collector_number: "102", + rarity: "uncommon", + colors: ["U"], + cmc: 2, + type_line: "Instant", + }], + ], seats: [], cards_per_pack: 14, pack_count: 3, @@ -141,6 +253,10 @@ describe("draftProtocol", () => { const decoded = await decodeDraftWireMessage(encoded); expect(decoded).toEqual(msg); + if (decoded.type === "draft_state_update") { + expect(decoded.view.sealed_packs).toEqual(longView.sealed_packs); + expect(decoded.view.pool_groups).toEqual(longView.pool_groups); + } }); it("round-trips a deck-carrying draft match start message", async () => { diff --git a/client/src/network/draftProtocol.ts b/client/src/network/draftProtocol.ts index 8b82e5fdd3..8cfea18f8f 100644 --- a/client/src/network/draftProtocol.ts +++ b/client/src/network/draftProtocol.ts @@ -39,8 +39,9 @@ import type { * 6 — durable authorized Bo3 intergame command ledger * 7 — forward authenticated match-host between-games observations * 8 — add Sealed event kind and deckbuilding-first start flow + * 9 — add engine-owned limited-pool presentation groups */ -export const DRAFT_PROTOCOL_VERSION = 8 as const; +export const DRAFT_PROTOCOL_VERSION = 9 as const; /** * Typed reason for a draft pause, used over the wire and on the i18n key path. diff --git a/client/src/pages/DraftLandingPage.tsx b/client/src/pages/DraftLandingPage.tsx index 829c23c971..a8effbee07 100644 --- a/client/src/pages/DraftLandingPage.tsx +++ b/client/src/pages/DraftLandingPage.tsx @@ -93,7 +93,7 @@ export function DraftLandingPage() { {/* Same bento action tiles as the home dashboard — one accent tone per mode — so the draft landing shares the home card grammar. */} -
+
} onClick={() => navigate("/draft/quick")} /> + } + onClick={() => navigate("/draft/quick?mode=sealed")} + />
)} @@ -485,6 +473,13 @@ export function DraftPage() { )} + {phase === "opening" && draftView && ( + useDraftStore.getState().completeSealedOpening()} + /> + )} + {phase === "launching" && ( Promise; startSealedDraft: (setPoolJson: string, setCode: string, setName: string, difficulty: number) => Promise; startCubeDraft: (cubeListText: string, cubeName: string, settings: CubeDraftSettings, difficulty: number) => Promise; + completeSealedOpening: () => void; resumeDraft: () => Promise; abandonDraft: () => Promise; pickCard: (cardInstanceId: string) => Promise; @@ -182,6 +183,15 @@ function persistDraftDebounced(): void { debounceTimer = setTimeout(persistDraft, 500); } +function phaseForView(view: DraftPlayerView, persistedPhase: DraftPhase): DraftPhase { + if (view.status === "Deckbuilding") { + return view.kind === "Sealed" && persistedPhase === "opening" + ? "opening" + : "deckbuilding"; + } + return view.status === "Pairing" ? "launching" : persistedPhase; +} + /** Apply an updated draft view after a pick (manual or auto) and persist. */ function applyPickResult(view: DraftPlayerView): void { const nextPhase: DraftPhase = @@ -249,7 +259,7 @@ export const useDraftStore = create()( draftId, adapter, view, - phase: view.status === "Deckbuilding" ? "deckbuilding" : "drafting", + phase: "opening", difficulty, selectedSet: setCode, selectedSetName: setName, @@ -296,6 +306,11 @@ export const useDraftStore = create()( persistDraft(); }, + completeSealedOpening: () => { + set({ phase: "deckbuilding" }); + persistDraft(); + }, + resumeDraft: async () => { const meta = loadActiveQuickDraft(); if (!meta) return; @@ -331,11 +346,7 @@ export const useDraftStore = create()( draftId: meta.id, adapter, view, - phase: view.status === "Deckbuilding" - ? "deckbuilding" - : view.status === "Pairing" - ? "launching" - : meta.phase, + phase: phaseForView(view, meta.phase), difficulty: meta.difficulty, selectedSet: meta.setCode, selectedSetName: meta.setName ?? null, @@ -372,11 +383,7 @@ export const useDraftStore = create()( draftId: meta.id, adapter, view, - phase: view.status === "Deckbuilding" - ? "deckbuilding" - : view.status === "Pairing" - ? "launching" - : meta.phase, + phase: phaseForView(view, meta.phase), difficulty: meta.difficulty, selectedSet: meta.setCode, selectedSetName: meta.setName ?? null, diff --git a/crates/draft-core/src/view.rs b/crates/draft-core/src/view.rs index 46f3c54f09..b5ce3a4a8a 100644 --- a/crates/draft-core/src/view.rs +++ b/crates/draft-core/src/view.rs @@ -44,6 +44,83 @@ pub struct SeatPublicView { pub pick_status: PickStatus, } +/// A stable, engine-defined category for a limited pool display group. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum DraftPoolGroupKind { + White, + Blue, + Black, + Red, + Green, + Multicolor, + Colorless, + Creature, + Instant, + Sorcery, + Enchantment, + Artifact, + Planeswalker, + Land, + Other, + ManaValue0, + ManaValue1, + ManaValue2, + ManaValue3, + ManaValue4, + ManaValue5, + ManaValue6Plus, +} + +/// One distinct card and the number of copies in a pool group. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct DraftPoolEntry { + pub card: DraftCardInstance, + pub count: usize, +} + +/// One ordered display group in a limited pool. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct DraftPoolGroup { + pub kind: DraftPoolGroupKind, + /// Number of physical cards before duplicate entries are collapsed. + pub total: usize, + pub cards: Vec, +} + +/// WUBRG card totals for the pool header. Multicolor cards count toward every +/// color they contain. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct DraftPoolColorCounts { + pub white: usize, + pub blue: usize, + pub black: usize, + pub red: usize, + pub green: usize, +} + +/// Pre-grouped, ordered presentation data for a player's limited pool. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct DraftPoolGroups { + pub color_groups: Vec, + pub type_groups: Vec, + pub cmc_groups: Vec, + pub color_counts: DraftPoolColorCounts, +} + +impl DraftPoolGroups { + /// Builds the engine-owned ordering, grouping, and duplicate counts for a + /// limited pool display. + pub fn from_pool(pool: &[DraftCardInstance]) -> Self { + Self { + color_groups: groups_for(pool, &COLOR_GROUP_ORDER, color_group, true), + type_groups: groups_for(pool, &TYPE_GROUP_ORDER, type_group, true), + cmc_groups: groups_for(pool, &CMC_GROUP_ORDER, mana_value_group, false), + color_counts: color_counts(pool), + } + } +} + /// Filtered draft state for a specific player. Built from scratch (not a reference /// into DraftSession) to prevent accidental hidden state leakage. #[derive(Debug, Clone, Serialize, Deserialize)] @@ -62,6 +139,13 @@ pub struct DraftPlayerView { pub current_pack: Option>, /// The viewer's drafted pool pub pool: Vec, + /// Engine-defined groups for displaying the viewer's pool without client-side + /// card classification, ordering, or deduplication. + pub pool_groups: DraftPoolGroups, + /// Each of the viewer's sealed packs, in opening order. Present only for + /// sealed events so clients can present the engine-generated pulls without + /// reconstructing packs from a flattened pool. + pub sealed_packs: Option>>, /// Public info for all seats pub seats: Vec, /// Total cards per pack (for UI progress display) @@ -238,6 +322,12 @@ pub fn filter_for_player(session: &DraftSession, seat_index: u8) -> DraftPlayerV .map(|p| p.0.clone()); let pool = session.pools.get(idx).cloned().unwrap_or_default(); + let sealed_packs = (session.kind == DraftKind::Sealed).then(|| { + pool.chunks(usize::from(session.config.cards_per_pack)) + .map(ToOwned::to_owned) + .collect() + }); + let pool_groups = DraftPoolGroups::from_pool(&pool); let is_drafting = session.status == DraftStatus::Drafting; @@ -298,6 +388,8 @@ pub fn filter_for_player(session: &DraftSession, seat_index: u8) -> DraftPlayerV pass_direction: session.pass_direction, current_pack, pool, + pool_groups, + sealed_packs, seats, cards_per_pack: session.config.cards_per_pack, pack_count: session.config.pack_count, @@ -313,6 +405,151 @@ pub fn filter_for_player(session: &DraftSession, seat_index: u8) -> DraftPlayerV } } +const COLOR_GROUP_ORDER: [DraftPoolGroupKind; 7] = [ + DraftPoolGroupKind::White, + DraftPoolGroupKind::Blue, + DraftPoolGroupKind::Black, + DraftPoolGroupKind::Red, + DraftPoolGroupKind::Green, + DraftPoolGroupKind::Multicolor, + DraftPoolGroupKind::Colorless, +]; + +const TYPE_GROUP_ORDER: [DraftPoolGroupKind; 8] = [ + DraftPoolGroupKind::Creature, + DraftPoolGroupKind::Instant, + DraftPoolGroupKind::Sorcery, + DraftPoolGroupKind::Enchantment, + DraftPoolGroupKind::Artifact, + DraftPoolGroupKind::Planeswalker, + DraftPoolGroupKind::Land, + DraftPoolGroupKind::Other, +]; + +const CMC_GROUP_ORDER: [DraftPoolGroupKind; 7] = [ + DraftPoolGroupKind::ManaValue0, + DraftPoolGroupKind::ManaValue1, + DraftPoolGroupKind::ManaValue2, + DraftPoolGroupKind::ManaValue3, + DraftPoolGroupKind::ManaValue4, + DraftPoolGroupKind::ManaValue5, + DraftPoolGroupKind::ManaValue6Plus, +]; + +fn groups_for( + pool: &[DraftCardInstance], + order: &[DraftPoolGroupKind], + classify: fn(&DraftCardInstance) -> DraftPoolGroupKind, + sort_by_cmc: bool, +) -> Vec { + order + .iter() + .filter_map(|kind| { + let cards: Vec<_> = pool + .iter() + .filter(|card| classify(card) == *kind) + .cloned() + .collect(); + let total = cards.len(); + (!cards.is_empty()).then(|| DraftPoolGroup { + kind: *kind, + total, + cards: sorted_entries(cards, sort_by_cmc), + }) + }) + .collect() +} + +fn sorted_entries(mut cards: Vec, sort_by_cmc: bool) -> Vec { + cards.sort_by(|left, right| { + if sort_by_cmc { + left.cmc + .cmp(&right.cmc) + .then_with(|| left.name.cmp(&right.name)) + } else { + left.name.cmp(&right.name) + } + }); + + let mut entries: Vec = Vec::new(); + for card in cards { + if let Some(entry) = entries + .last_mut() + .filter(|entry| entry.card.name == card.name) + { + entry.count += 1; + } else { + entries.push(DraftPoolEntry { card, count: 1 }); + } + } + entries +} + +fn color_group(card: &DraftCardInstance) -> DraftPoolGroupKind { + match card.colors.as_slice() { + [] => DraftPoolGroupKind::Colorless, + [_color, _second, ..] => DraftPoolGroupKind::Multicolor, + [color] => match color.as_str() { + "W" => DraftPoolGroupKind::White, + "U" => DraftPoolGroupKind::Blue, + "B" => DraftPoolGroupKind::Black, + "R" => DraftPoolGroupKind::Red, + "G" => DraftPoolGroupKind::Green, + _ => DraftPoolGroupKind::Colorless, + }, + } +} + +fn type_group(card: &DraftCardInstance) -> DraftPoolGroupKind { + let type_line = card.type_line.to_ascii_lowercase(); + if type_line.contains("creature") { + DraftPoolGroupKind::Creature + } else if type_line.contains("instant") { + DraftPoolGroupKind::Instant + } else if type_line.contains("sorcery") { + DraftPoolGroupKind::Sorcery + } else if type_line.contains("enchantment") { + DraftPoolGroupKind::Enchantment + } else if type_line.contains("artifact") { + DraftPoolGroupKind::Artifact + } else if type_line.contains("planeswalker") { + DraftPoolGroupKind::Planeswalker + } else if type_line.contains("land") { + DraftPoolGroupKind::Land + } else { + DraftPoolGroupKind::Other + } +} + +fn mana_value_group(card: &DraftCardInstance) -> DraftPoolGroupKind { + match card.cmc { + 0 => DraftPoolGroupKind::ManaValue0, + 1 => DraftPoolGroupKind::ManaValue1, + 2 => DraftPoolGroupKind::ManaValue2, + 3 => DraftPoolGroupKind::ManaValue3, + 4 => DraftPoolGroupKind::ManaValue4, + 5 => DraftPoolGroupKind::ManaValue5, + _ => DraftPoolGroupKind::ManaValue6Plus, + } +} + +fn color_counts(pool: &[DraftCardInstance]) -> DraftPoolColorCounts { + let mut counts = DraftPoolColorCounts::default(); + for card in pool { + for color in &card.colors { + match color.as_str() { + "W" => counts.white += 1, + "U" => counts.blue += 1, + "B" => counts.black += 1, + "R" => counts.red += 1, + "G" => counts.green += 1, + _ => {} + } + } + } + counts +} + fn compute_standings(session: &DraftSession) -> Vec { if session.pairings.is_empty() { return Vec::new(); @@ -467,6 +704,19 @@ mod tests { .unwrap(); } + fn draft_card(name: &str, colors: &[&str], cmc: u8, type_line: &str) -> DraftCardInstance { + DraftCardInstance { + instance_id: name.to_string(), + name: name.to_string(), + set_code: "TST".to_string(), + collector_number: "1".to_string(), + rarity: "common".to_string(), + colors: colors.iter().map(ToString::to_string).collect(), + cmc, + type_line: type_line.to_string(), + } + } + #[test] fn view_contains_viewers_current_pack() { let (mut session, source) = test_session(8); @@ -493,6 +743,87 @@ mod tests { assert_eq!(view.pool[0].instance_id, session.pools[0][0].instance_id); } + #[test] + fn sealed_view_preserves_the_viewers_pack_boundaries() { + let (mut session, source) = test_session(2); + session.kind = DraftKind::Sealed; + session.config.kind = DraftKind::Sealed; + session.config.pack_count = 6; + session::apply(&mut session, DraftAction::StartDraft, Some(&source)).unwrap(); + + let view = filter_for_player(&session, 0); + let sealed_packs = view + .sealed_packs + .expect("sealed view includes opening packs"); + + assert_eq!(sealed_packs.len(), 6); + assert!(sealed_packs.iter().all(|pack| pack.len() == 14)); + assert_eq!(sealed_packs.concat(), view.pool); + } + + #[test] + fn pool_groups_are_engine_ordered_and_deduplicated() { + let pool = vec![ + draft_card("Adept", &["W"], 2, "Artifact Creature — Wizard"), + draft_card("Adept", &["W"], 2, "Artifact Creature — Wizard"), + draft_card("Bolt", &["R"], 1, "Instant"), + draft_card("Charm", &["U", "R"], 3, "Sorcery"), + draft_card("Field", &[], 0, "Land"), + ]; + + let groups = DraftPoolGroups::from_pool(&pool); + + assert_eq!( + groups + .color_groups + .iter() + .map(|group| group.kind) + .collect::>(), + vec![ + DraftPoolGroupKind::White, + DraftPoolGroupKind::Red, + DraftPoolGroupKind::Multicolor, + DraftPoolGroupKind::Colorless, + ] + ); + assert_eq!(groups.color_groups[0].cards[0].count, 2); + assert_eq!(groups.color_groups[0].total, 2); + assert_eq!( + groups + .type_groups + .iter() + .map(|group| group.kind) + .collect::>(), + vec![ + DraftPoolGroupKind::Creature, + DraftPoolGroupKind::Instant, + DraftPoolGroupKind::Sorcery, + DraftPoolGroupKind::Land, + ] + ); + assert_eq!(groups.type_groups[0].cards[0].card.name, "Adept"); + assert_eq!(groups.type_groups[0].cards[0].count, 2); + assert_eq!(groups.color_counts.white, 2); + assert_eq!(groups.color_counts.red, 2); + } + + #[test] + fn mana_value_group_kinds_match_the_wire_contract() { + let values = [ + (DraftPoolGroupKind::ManaValue0, "mana_value0"), + (DraftPoolGroupKind::ManaValue1, "mana_value1"), + (DraftPoolGroupKind::ManaValue2, "mana_value2"), + (DraftPoolGroupKind::ManaValue3, "mana_value3"), + (DraftPoolGroupKind::ManaValue4, "mana_value4"), + (DraftPoolGroupKind::ManaValue5, "mana_value5"), + (DraftPoolGroupKind::ManaValue6Plus, "mana_value6_plus"), + ]; + + for (kind, expected) in values { + assert_eq!(serde_json::to_value(kind).unwrap(), expected); + } + } + #[test] fn view_contains_public_status_fields() { let (mut session, source) = test_session(8); diff --git a/crates/engine/data/mtgjson-vintage b/crates/engine/data/mtgjson-vintage index 96d70ec068..03d87f1551 100644 --- a/crates/engine/data/mtgjson-vintage +++ b/crates/engine/data/mtgjson-vintage @@ -1 +1 @@ -2026-08-09 +2026-08-10 diff --git a/crates/engine/src/ai_support/candidates.rs b/crates/engine/src/ai_support/candidates.rs index 1a35510ad6..aea648333b 100644 --- a/crates/engine/src/ai_support/candidates.rs +++ b/crates/engine/src/ai_support/candidates.rs @@ -5383,6 +5383,7 @@ mod tests { casting_restrictions: Vec::new(), casting_options: Vec::new(), layout_kind: Some(LayoutKind::Prepare), + parse_warnings: vec![], } } diff --git a/crates/engine/src/ai_support/mod.rs b/crates/engine/src/ai_support/mod.rs index 1f7f720675..3de9b0076d 100644 --- a/crates/engine/src/ai_support/mod.rs +++ b/crates/engine/src/ai_support/mod.rs @@ -6,6 +6,7 @@ mod evoke; pub mod filter; mod payment_continuation; mod prospective_mana; +mod shortcut_efficacy; mod swarm; mod targeted_exchange; @@ -1352,25 +1353,48 @@ fn has_activatable_sacrifice_for_mana(state: &GameState) -> bool { && mana_actions_include_meaningful_sacrifice(state, &activatable_object_mana_actions(state)) } +/// The mana activations [`has_activatable_sacrifice_for_mana`] counts, as +/// items rather than as a yes/no. +/// +/// SINGLE AUTHORITY for the predicate. `smart_shortcut_response`'s stage 2 has +/// to classify exactly the actions stage 1 counted, and stage 1 counts these — +/// which the flat `legal_actions` list structurally cannot contain (they live +/// in `legal_actions_by_object` only). Re-testing the same shape at that call +/// site would be a parallel copy free to drift, so the call site consumes this +/// iterator and `mana_actions_include_meaningful_sacrifice` is defined as its +/// emptiness. +fn meaningful_sacrifice_mana_actions<'a>( + state: &'a GameState, + object_mana_actions: &'a [GameAction], +) -> impl Iterator { + object_mana_actions.iter().filter(move |action| { + matches!( + action, + GameAction::ActivateAbility { + source_id, + ability_index, + } if activate_ability_is_meaningful_priority(state, *source_id, *ability_index) + ) + }) +} + /// Slice-taking core of [`has_activatable_sacrifice_for_mana`]: given a /// precomputed activatable mana-action sweep, true iff any action is a /// meaningful (sacrifice-for-mana) mana activation. Extracted so /// `auto_pass_recommended` can compute the sweep ONCE and share it between the /// G1 beneficial-mana-tap hold (rung 5) and this rung-9 sac check, avoiding the /// PR #5229 double-evaluation of the mana-action sweep. +/// +/// `Iterator::any(p)` and `.filter(p).next().is_some()` agree on every input +/// (both are the first-match short-circuit over the same predicate), so this +/// re-expression leaves the loop-firewall and auto-pass answers unchanged. fn mana_actions_include_meaningful_sacrifice( state: &GameState, object_mana_actions: &[GameAction], ) -> bool { - object_mana_actions.iter().any(|action| { - matches!( - action, - GameAction::ActivateAbility { - source_id, - ability_index, - } if activate_ability_is_meaningful_priority(state, *source_id, *ability_index) - ) - }) + meaningful_sacrifice_mana_actions(state, object_mana_actions) + .next() + .is_some() } /// True when `actions` contains a priority action that materially changes the @@ -1387,24 +1411,22 @@ pub fn has_meaningful_priority_action(state: &GameState, actions: &[GameAction]) || has_activatable_sacrifice_for_mana(state) } -/// PR-7 Phase 4c (LOW-2): CR 732.2b/c + CR 104.4b — an AI opponent polled on an -/// OPTIONAL loop shortcut. The offer is raised only for optional loops the polled -/// player CAN break (a Path A optional drain, `WaitingFor::LoopShortcut`); the win -/// is a loss condition for every polled opponent (single-faller: see -/// `interactive_loop_bridge`'s Path A gate, CR 104.2a). SELF-PRESERVATION: if this -/// player has a meaningful priority action (a way to break the loop), name -/// `Shorten{0}` — the engine realizes Shorten as a real `WaitingFor::Priority` -/// window (`game::engine::apply_action`'s `RespondToShortcut(Shorten)` arm) where -/// it can act — rather than Accept its own loss. No meaningful action ⇒ Accept -/// (nothing to do). Single authority for all 3 `RespondToShortcut` emission sites -/// (engine `candidates.rs` + phase-ai `projection.rs`/`search.rs`) so the -/// self-preservation heuristic can't drift between them. Reuses the exact -/// `no_living_player_has_meaningful_priority_action` probe recipe -/// (`game::engine`), scoped to the single polled player. -pub fn smart_shortcut_response( +/// The probe [`smart_shortcut_response`] folds over: `state` re-parked at +/// `polled_player`'s priority with auto-pass cleared and layers flushed, plus +/// that state's flat priority actions. Mirrors +/// `game::engine`'s `no_living_player_has_meaningful_priority_action` recipe, +/// scoped to the single polled player. +/// +/// Public because the shortcut tests' reach-guards must assert on THIS list and +/// THIS state. A private copy of the recipe in a test silently starts measuring +/// a different action set — and a different `has_meaningful_priority_action` +/// answer, since that predicate's sacrifice-for-mana rung only fires while +/// `waiting_for` is `Priority`, which is true of the probe state and false of +/// the `RespondToShortcut` state the caller holds. +pub fn shortcut_probe( state: &GameState, polled_player: PlayerId, -) -> crate::analysis::loop_check::ShortcutResponse { +) -> (casting::PriorityCastProbe, Vec) { let mut probe_state = state.clone(); probe_state.auto_pass.clear(); probe_state.priority_player = polled_player; @@ -1414,11 +1436,167 @@ pub fn smart_shortcut_response( layers::flush_layers(&mut probe_state); let probe = casting::PriorityCastProbe::from_flushed_state(probe_state, polled_player); let actions = flat_priority_actions_with_probe(probe.state(), Some(&probe)); - if has_meaningful_priority_action(probe.state(), &actions) { + (probe, actions) +} + +/// The actions [`smart_shortcut_response`]'s stage 2 classifies, given a probe +/// state and the flat priority list stage 1 read. +/// +/// COVERAGE INVARIANT — stage 2 must classify every action stage 1 counted as +/// meaningful. The flat list does not satisfy that on its own: +/// [`has_meaningful_priority_action`] is a disjunction, and its second rung +/// ([`has_activatable_sacrifice_for_mana`]) reads `state`, not `actions`, so it +/// counts sacrifice-for-mana activations that `legal_actions` structurally omits +/// (they live in `legal_actions_by_object` only — see issue #544). Folding stage +/// 2 over the flat list alone therefore lets a seat whose ONLY meaningful action +/// is such an activation Accept BY OMISSION: an action stage 2 never sees +/// reaches no arm, so `shortcut_efficacy`'s fail-closed `_ => MayInterfere` +/// default cannot protect it, and a false Accept is the direction the module doc +/// says can lose a game. +/// +/// `activatable_object_mana_actions` is gated on `waiting_for` — the probe state +/// is re-parked at `Priority`, which is the same gate +/// `has_activatable_sacrifice_for_mana` passes there, so the two stages read the +/// SAME sweep. The added items come from +/// [`meaningful_sacrifice_mana_actions`], the single authority stage 1's rung is +/// also defined in terms of; re-testing the shape here would be a parallel copy +/// free to drift. +/// +/// Deliberately does NOT add the whole mana sweep, only the subset stage 1 +/// counts: a plain land tap is not a meaningful priority action to stage 1, and +/// widening past that would buy `Shorten`s stage 1 never asked for. +/// +/// Public for the same reason [`shortcut_probe`] is: the shortcut tests' +/// coverage row must assert on THIS set, and a private copy of the recipe would +/// silently start measuring a different one. +pub fn stage_two_action_set( + probe_state: &GameState, + flat_actions: &[GameAction], +) -> Vec { + let mana_actions = activatable_object_mana_actions(probe_state); + flat_actions + .iter() + .chain(meaningful_sacrifice_mana_actions( + probe_state, + &mana_actions, + )) + .cloned() + .collect() +} + +/// PR-7 Phase 4c (LOW-2): CR 732.2b/c + CR 104.4b — an AI opponent polled on an +/// OPTIONAL or bounded loop shortcut. The win is a loss condition for every +/// polled opponent (single-faller: see `interactive_loop_bridge`'s Path A gate, +/// CR 104.2a). +/// +/// SINGLE AUTHORITY, and what it does NOT cover. MEASURED — production (i.e. +/// non-`#[cfg(test)]`) code under `crates/*/src/` builds a +/// `GameAction::RespondToShortcut` value at exactly four places: +/// * `ai_support::candidates::candidate_actions_broad_with_probe`, +/// * `phase_ai::projection::resolve_choice`, +/// * `phase_ai::search::fallback_action` — these three are AI seats and all +/// route here, so the heuristic cannot drift between them; and +/// * `game::interaction::materialize_shortcut_reply_response`, which turns a +/// HUMAN player's submitted `InteractionShortcutReply` into the action. That +/// one is deliberately NOT covered: running a stated human choice through an +/// AI heuristic would overwrite it. It is also the only site that can emit a +/// non-zero `at_iteration` — every AI site emits `Shorten { at_iteration: 0 }`. +/// +/// `candidate_actions_broad_with_probe` additionally routes +/// `WaitingFor::RespondToPrecastCopyShortcut` here and maps the answer onto +/// `PrecastCopyShortcutResponse`, so this function answers BOTH accept-or-shorten +/// windows. That is intended: both windows ask the identical question — is a real +/// priority window worth taking here — so a seat whose only action cannot touch +/// the loop should decline both. `shorten_efficacy.rs`'s +/// `v8_precast_window_takes_the_same_efficacy_answer` measures that window rather +/// than assuming it. +/// +/// TWO STAGES. +/// +/// **Stage 1 — POSSIBILITY, byte-identical to the shipped predicate.** Reuses +/// the exact `no_living_player_has_meaningful_priority_action` probe recipe +/// (`game::engine`), scoped to the single polled player. No meaningful priority +/// action ⇒ Accept, exactly as before. `has_meaningful_priority_action` is +/// wired into the CR 732.5 NON-COMPULSION rule — +/// "No player can be forced to perform an action that would end a loop other +/// than actions called for by objects involved in the loop" — and is untouched +/// here. +/// +/// **Stage 2 — EFFICACY (AI POLICY, no CR licence claimed).** CR 732.2b grants +/// an unconditioned accept-or-shorten option and CR 732.2c requires only that a +/// shortening player's next choice be +/// *different*, which a fetchland activation already satisfies; nothing in the +/// Comprehensive Rules states an efficacy criterion. This stage is therefore +/// policy, and it declines to burn a real priority window on a response that +/// cannot change the outcome: +/// - arm (A): the offer's own predicted result already crowns this seat, so +/// shortening moves the game away from a win it already holds; +/// - arm (B): every action stage 1 counted as meaningful is confined to this +/// seat's own resources (`shortcut_efficacy::any_action_may_interfere`), so +/// no available choice touches the loop. Scoped to what stage 1 counted, not +/// to "every action this seat could take": the stage-2 set is a SUPERSET of, +/// and never smaller than, what stage 1 counted — the flat list enters +/// whole, so it also carries actions the stage-1 fold +/// (`flat_actions_have_meaningful_priority`) does not count as meaningful, +/// `PassPriority` and mana-ability activations among them, and extra items +/// can only push toward `MayInterfere`. See the coverage invariant at the +/// call below; the wider phrasing would claim coverage of shapes neither +/// stage enumerates. +/// +/// Otherwise the seat still Shortens and gets its window +/// (`game::engine::apply_action`'s `RespondToShortcut(Shorten)` arm). +/// +/// READ-ORDER: the proposal is read off the ORIGINAL `state`, before +/// [`shortcut_probe`] re-parks its clone at `Priority` — the probe state carries +/// no offer at all, so reading the crown from it would make arm (A) dead code. +pub fn smart_shortcut_response( + state: &GameState, + polled_player: PlayerId, +) -> crate::analysis::loop_check::ShortcutResponse { + // Both accept-or-shorten windows are named, so neither is answered by + // accident. The wildcard is not removable — `WaitingFor` has 128 variants and + // enumerating 126 `=> None` arms here would be noise, not a guard — and this + // two-named-arms + `_` shape is the module's existing idiom for the same + // question (`game::precast_copy_shortcut::normalize_untrusted_restore`, + // `::rekey_after_trusted_restore`). + let crowned_winner = match &state.waiting_for { + WaitingFor::RespondToShortcut { proposal, .. } => proposal.predicted_winner, + // STRUCTURAL, not an oversight: `RespondToPrecastCopyShortcut` carries no + // proposal summary and therefore no `predicted_winner` field, so the + // pre-cast route has no crown to read and arm (A) is inapplicable rather + // than skipped. Stage 1 and arm (B) do apply, and both run below. + WaitingFor::RespondToPrecastCopyShortcut { .. } => None, + _ => None, + }; + + let (probe, actions) = shortcut_probe(state, polled_player); + if !has_meaningful_priority_action(probe.state(), &actions) { + // CR 732.2c: nothing to do — agree to take the shortcut. + return crate::analysis::loop_check::ShortcutResponse::Accept; + } + + // Arm (A). Keyed on `predicted_winner`, never `proposer`: CR 732.2a lets a + // player propose a shortcut whose outcome crowns someone else, and the + // proposer is excluded from the APNAP response queue anyway. + if crowned_winner == Some(polled_player) { + return crate::analysis::loop_check::ShortcutResponse::Accept; + } + + // Arm (B). No bounded/unbounded exemption: an `IterationCount::Fixed(n)` + // offer ends the game just as surely as an `UntilLethal` one, so both + // classes take the identical rule. + // + // The fold reads [`stage_two_action_set`], never the bare flat `actions` — + // see that function for the coverage invariant and what folding over the + // flat list alone would silently Accept. + if shortcut_efficacy::any_action_may_interfere( + probe.state(), + polled_player, + &stage_two_action_set(probe.state(), &actions), + ) { // CR 732.2b: name an earlier stopping point — take my window instead of losing. crate::analysis::loop_check::ShortcutResponse::Shorten { at_iteration: 0 } } else { - // CR 732.2c: nothing to do — agree to take the shortcut. crate::analysis::loop_check::ShortcutResponse::Accept } } diff --git a/crates/engine/src/ai_support/shortcut_efficacy.rs b/crates/engine/src/ai_support/shortcut_efficacy.rs new file mode 100644 index 0000000000..ad2fde44da --- /dev/null +++ b/crates/engine/src/ai_support/shortcut_efficacy.rs @@ -0,0 +1,3992 @@ +// engine-citation-gate: symbol anchors only +//! Controller-relative confinement of a polled player's priority window — +//! stage 2 of [`super::smart_shortcut_response`]. +//! +//! CITATION FORM: rule NUMBER only, matching the enrolled test sibling +//! `tests/integration/shorten_efficacy.rs`. The number IS the greppable +//! heading — `grep '^400.1' docs/MagicCompRules.txt` resolves any citation +//! below. `docs/MagicCompRules.txt` is gitignored and re-fetched per checkout, +//! so a line anchor pins a citation to whichever rules revision its author +//! happened to hold. +//! +//! # 1. This is AI POLICY, not a rule +//! +//! CR 732.2b grants an **unconditioned** binary +//! option: "Each other player, in turn order starting after the player who +//! suggested the shortcut, may either accept the proposed sequence, or shorten +//! it by naming a place where they will make a game choice that's different +//! than what's been proposed." There is no criterion, no outcome test, and no +//! efficacy test; the rule explicitly does not even require the shortening +//! player to say what the new choice will be. CR 732.2c adds the only +//! obligation, and it is downstream: "the player who now has priority must make +//! a different game choice than what was originally proposed for that player." +//! Activating a fetchland IS a different game choice, so a fetchland Shorten +//! fully satisfies it. +//! +//! Nothing in the Comprehensive Rules therefore grounds this module's decision. +//! It is an AI policy: a seat whose only available response cannot touch the +//! loop spends its window achieving nothing, and the engine should not burn a +//! real priority window on it. Every CR number below is cited **only for what +//! its text says**, and is used as an *input* the classifier reads (which zones +//! are per-player, who owns what) — never as a licence for the decision. +//! +//! # 2. Fail-closed direction, and why the wildcard is conservative HERE +//! +//! [`WindowReach::MayInterfere`] is the default for every unrecognized shape. +//! That direction is deliberate and is the opposite of the discipline +//! `game::ability_scan` enforces on itself. `ability_scan`'s default is +//! `Axes::NONE` ("this ability reads nothing"), which is its *unsafe* +//! direction — a newly added reader classified inert would ride a false +//! auto-resolution — so that module forbids wildcards outright. Here the +//! default says "this action might interfere", which can produce only a **false +//! `Shorten`** (the polled seat takes a priority window it did not need — it +//! costs beats, never a game) and **never a false `Accept` from a shape it does +//! not recognize** (a seat accepting its own loss while holding a real out). +//! The qualifier is load-bearing; see the residual below. An unknown nesting +//! container is interference regardless of what it nests, so there is no +//! nested-carrier set and no recursion-arm set left to drift, and no +//! hand-maintained allowlist to rot. +//! +//! That argument covers unrecognized *shapes*, and it leaves exactly one +//! residual uncovered: a clause the PARSER silently swallows never becomes a +//! shape here at all, so the ability this module is handed looks strictly MORE +//! confined than the printed card is — and narrowing apparent reach is the +//! dangerous direction, because it produces a false `Accept`, not a false +//! `Shorten`. The residual is stated structurally rather than pinned to a card, +//! because a card witness is only valid until the classifier moves under it: +//! this doc previously named `Invoke Justice[0]` — a lone +//! [`Effect::ChangeZone`] (`origin: Graveyard`, `target: Typed{controller: +//! You}`, no cost, no `sub_ability`) whose printed sentence continues "then +//! distribute four +1/+1 counters among any number of creatures and/or Vehicles +//! target player controls" — and the entry gate added below has since +//! reclassified it `MayInterfere`, because its battlefield entry is not +//! provably tapped. That narrowed the residual; it did not close it. What +//! remains live is the same shape at a destination the entry gate does not +//! constrain: a non-battlefield destination, or a provably tapped entry. No +//! widening of this module can reach any of it — it cannot read a clause that +//! never arrived, so the residual is owned by the parser, not the classifier. +//! +//! The structural guard is the compiler, not a test: +//! [`ability_window_reach`] destructures [`AbilityDefinition`] **without +//! `..`**, and every allowlisted [`Effect`] arm is likewise `..`-free, so a new +//! field on the definition or on an allowlisted variant fails to compile until +//! it is classified. Precedent: `game::ability_scan::ability_definition_axes`. +//! +//! # 3. `analysis::ability_graph::collect_effects` is deliberately NOT used +//! +//! Two independent reasons, one mechanism. Its inner recursion ends `_ => {}` +//! (`analysis/ability_graph.rs`, whose own doc says "a wildcard covers the leaf +//! variants") — silent fail-open. And it walks **4 of `AbilityDefinition`'s 38 +//! fields** (`effect`, `sub_ability`, `else_ability`, `mode_abilities`); it +//! never inspects `cost` at all, so `Sacrifice` / `Discard` / `Mill` / +//! `ExileMaterials` costs — every one of which can reach another player's +//! resources — would be structurally invisible. + +use crate::game::game_object::GameObject; +use crate::types::ability::{ + AbilityCost, AbilityDefinition, ControllerRef, Effect, FilterProp, PlayerFilter, + SearchDestinationSplit, TargetFilter, +}; +use crate::types::actions::GameAction; +use crate::types::game_state::GameState; +use crate::types::identifiers::ObjectId; +use crate::types::player::PlayerId; +use crate::types::zones::{EtbTapState, Zone}; + +/// How far a single available action can reach, relative to the player who +/// would take it. +/// +/// A fourth axis, orthogonal to the three the engine already carries: +/// `ai_support::FlatPriorityActionClass` classifies *action shape*, +/// `game::ability_rw::WriteScope` classifies *object identity*, and +/// `game::ability_scan::Axes` classifies *AST reads*. This one classifies +/// **controller-relative confinement**. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum WindowReach { + /// Everything this action can touch belongs to the player taking it. + OwnResourcesOnly, + /// This action can reach something outside the actor's own resources — + /// or its shape is not recognized, which is treated identically. + MayInterfere, +} + +impl WindowReach { + fn of(own_resources_only: bool) -> Self { + if own_resources_only { + Self::OwnResourcesOnly + } else { + Self::MayInterfere + } + } + + /// Absorbing fold: one interfering component makes the whole interfering. + fn or(self, other: Self) -> Self { + match (self, other) { + (Self::OwnResourcesOnly, Self::OwnResourcesOnly) => Self::OwnResourcesOnly, + _ => Self::MayInterfere, + } + } + + fn may_interfere(self) -> bool { + matches!(self, Self::MayInterfere) + } +} + +/// True only when every object this filter can match is PROVEN to belong to the +/// acting player. +/// +/// CR 400.1: "Each player has their own library, hand, and graveyard. +/// The other zones are shared by all players." A bare `Zone` reference is +/// therefore ambiguous by rule — it can never answer *whose* zone — so the +/// player-qualified authority has to come from the filter. CR 400.3 +/// names exactly three zones ("If an object would go to any library, graveyard, +/// or hand other than its owner's, it goes to its owner's corresponding zone") +/// and CR 108.3 defines the owner, which is why owner/controller, not +/// zone, is the axis this predicate reads. +/// +/// Unproven is not owned: everything else (`Any`, `None`, `Player`, `Opponent`, +/// `ParentTarget*`, anaphors, specific ids) is `false`. +/// +/// SCOPE, and the name is wider than what this proves. Both `true` arms are +/// CONTROL arms, not ownership arms: CR 109.5 defines "you"/"your" on an object +/// as its CONTROLLER, so `SelfRef`, `Controller` and `Typed{controller: You}` all +/// resolve through control, and CR 110.2 makes owner and controller +/// independent. Ownership is proven separately and at BOARD level by +/// [`actor_owns_everything_they_control`], which +/// [`any_action_may_interfere`] applies before it folds any action; this +/// predicate is sound only underneath that conjunct. +fn filter_is_actor_owned(filter: &TargetFilter) -> bool { + match filter { + TargetFilter::SelfRef | TargetFilter::Controller => true, + TargetFilter::Typed(typed) => { + typed.controller == Some(ControllerRef::You) + || typed.properties.iter().any(|prop| { + matches!( + prop, + FilterProp::Owned { + controller: ControllerRef::You + } + ) + }) + } + // An `Or` matches an object when ANY leg does, so every leg must be + // proven; an `And` matches only when ALL legs do, so one proven leg is + // enough. The emptiness guards keep a degenerate `filters: []` from + // being proven by a vacuous `all()`. + TargetFilter::Or { filters } => { + !filters.is_empty() && filters.iter().all(filter_is_actor_owned) + } + TargetFilter::And { filters } => filters.iter().any(filter_is_actor_owned), + _ => false, + } +} + +/// Does the actor OWN every permanent they control? +/// +/// [`filter_is_actor_owned`] proves CONTROL, because CR 109.5 defines "you" and +/// "your" on an object as its controller. CR 110.2 makes owner and controller +/// independent axes ("A permanent's owner is the same as the owner of the card +/// that represents it"), so a controlled-but-not-owned permanent breaks every +/// place this module treats a control proof as a confinement proof. The sharp +/// one is CR 701.21a: "To sacrifice a permanent, its controller moves it from the +/// battlefield directly to its OWNER'S graveyard" — so the allowlisted +/// `AbilityCost::Sacrifice` of a permanent the actor controls but does not own +/// puts a card into ANOTHER player's graveyard, which is that player's own zone +/// per CR 400.1 ("Each player has their own library, hand, and graveyard") and +/// therefore reach by construction. +/// +/// SET-LEVEL BY NECESSITY, not by preference. Threading ownership per matched +/// object is structurally impossible at the filter layer: `TargetFilter::Controller` +/// is a bare unit variant carrying no id at all, and `TargetFilter::Typed` is a +/// SET PREDICATE describing a class rather than naming a member, so neither can be +/// resolved to the objects it would match without evaluating it against the board. +/// The single variant that does carry an id — `SpecificObject { id }` — already +/// falls to `filter_is_actor_owned`'s `_` arm and returns `false`. So the only +/// place the ownership fact can be discharged is the board, once, over every +/// object the actor controls. +/// +/// Board facts are action-independent, so the caller evaluates this ONCE rather +/// than per action. +fn actor_owns_everything_they_control(state: &GameState, actor: PlayerId) -> bool { + state + .objects + .values() + .all(|o| o.controller != actor || o.owner == actor) +} + +/// Is a landing zone confined — i.e. can the seat NOT act on what arrives there, +/// inside the window the Shorten hands back? +/// +/// This is the single authority for that question, and it exists because the two +/// doors out of a library drifted apart exactly once: the `ChangeZone` arm gated +/// `Zone::Hand` while the `SearchLibrary` split arm gated only `Zone::Battlefield`, +/// so a `SearchDestinationSplit` routing cards to hand stayed classified confined. +/// MEASURED at that point: all twelve split carriers in `card-data.json` route +/// something to hand — nine via `rest_destination` (the Cultivate class: land to +/// the battlefield tapped, the REST to hand) and three via `primary_destination` +/// (Final Parting, Fork in the Road, Jarad's Orders). Two call sites answering one +/// question is how that happens, so there is now one function and no second answer. +/// +/// * `Zone::Battlefield` — CR 110.5b: permanents enter untapped unless something +/// says otherwise, and an untapped permanent taps for mana during the very cast +/// it funds (CR 601.2g). Only a PROVABLY tapped arrival is confined. +/// * `Zone::Hand` — a card in hand is a castable card. After the spell that put it +/// there resolves the active player receives priority (CR 117.3b) and priority +/// then passes in turn order (CR 117.3d), so the responding seat gets it back +/// still inside this window. The AST carries nothing that could prove the card +/// uncastable. +/// * `Zone::Stack` — strictly stronger than the hand case, and the one a `_` arm hid. +/// CR 405.1: a cast spell's card is put on the stack; CR 608.1: the object on top +/// of the stack resolves once all players pass. A card that LANDS there is past +/// casting already and resolves inside this very window, so the seat does not even +/// need priority to get its effect. +/// * `Zone::Command` — CR 903.8: "A player may cast a commander they own from the +/// command zone", and CR 114.1 puts emblems there carrying abilities of their own. +/// It is a zone the seat acts FROM, so it belongs with hand rather than with the +/// need-further-permission group this doc previously filed it under. +/// * `Zone::Library` / `Zone::Graveyard` / `Zone::Exile` — the card lands where the +/// actor needs some FURTHER permission to use it, and that permission would itself +/// be an ability this fold already reads. +/// +/// Callers still apply their own extra conjuncts (the battlefield riders on +/// `ChangeZone`); this answers the destination question only. +fn landing_zone_is_confined(destination: Zone, tapped: EtbTapState) -> bool { + // EXHAUSTIVE ON PURPOSE — no wildcard. `Zone` is a closed seven-variant enum + // (CR 400.1), and a `_` arm here is FAIL-OPEN in a module whose every other + // default is fail-closed: it answers "confined" for any variant nobody thought + // about. That is not hypothetical. This function shipped as + // `Battlefield => …, Hand => false, _ => true`, and the `_` silently absorbed + // `Zone::Stack` — a live `ChangeZone` destination (`game::zones`) — so a node + // landing a card on the stack read as confined, contributing `OwnResourcesOnly` to + // a fold whose whole purpose is to decide whether an Accept is safe. (Whether any + // one node's verdict becomes an Accept depends on the rest of the fold; what is + // certain is that this arm was voting the wrong way.) MEASURED at this candidate's + // projection: 0 `Stack` destinations and 1 `Command` (Hellkite Courser, whose node + // lives in `triggers` and is therefore never folded), so the fix is latent today + // and is rated on what it does when reached, not on how often it is reached. + // The doc above listed "graveyard, library, exile, command" as the remainder and + // never mentioned Stack, which is exactly what a wildcard costs you: the arm list + // stops being a claim the compiler checks. Adding a `Zone` variant must break this + // build. + match destination { + Zone::Battlefield => matches!(tapped, EtbTapState::Tapped), + Zone::Hand | Zone::Stack | Zone::Command => false, + Zone::Library | Zone::Graveyard | Zone::Exile => true, + } +} + +/// Reach of one effect node. Three allowlisted shapes; everything else is +/// interference by default (see the module doc, §2). +fn effect_window_reach(effect: &Effect) -> WindowReach { + match effect { + // Absent `target_player` is an engine parser convention for "the + // actor's own library". It is a true description of engine behaviour + // and it is NOT the safety warrant for this arm, because it is NOT + // reliable: MEASURED over `data/card-data.json`, five abilities whose + // Oracle text explicitly names a FOREIGN library still carry a + // `SearchLibrary` node with `target_player: None` — Head Games[0], + // Jester's Mask[0], Haunting Echoes[0], Jace, Architect of Thought[2], + // and the SECOND search of Sadistic Sacrament[0] (whose first search + // does carry a `Typed` target_player). No aggregate count is claimed + // here: "how many abilities name a foreign library" has no + // scope-independent answer, and an unscoped census is worse than none. + // + // What makes the arm safe is the ABSORBING ability-level fold in + // `ability_window_reach`: a foreign-library ability carries some + // sibling effect or cost that folds to `MayInterfere` regardless of + // what `target_player` says. `Haunting Echoes[0]` is the in-suite + // witness for that fold — see + // `a_foreign_library_search_is_confined_alone_then_absorbed`. Bribery[0] + // and Praetor's Grasp[0] are also `MayInterfere`, but they witness the + // OTHER path: both carry `target_player: Typed`, so this arm catches + // them directly and the fold never has to. + // + // CR 400.1 is cited here only for why a bare `Zone::Library` + // reference is ambiguous in the first place. + // + // `split` IS read, and it is the second door into the battlefield. A + // cultivate-class search moves its own found cards + // (`SearchDestinationSplit { primary_destination, primary_enter_tapped, + // rest_destination }`) WITHOUT any `ChangeZone` sub-ability, so the tap + // gate on that arm cannot see it. Same rule (CR 110.5b), same + // fail-closed direction: a split may reach the battlefield only through + // its `primary_destination` and only when `primary_enter_tapped` proves + // `Tapped`. `rest_destination` carries NO tap state at all, so it can + // never be proven tapped and a battlefield `rest_destination` is reach + // by construction. + Effect::SearchLibrary { + source_zones: _, + filter: _, + count: _, + reveal: _, + target_player, + selection_constraint: _, + split, + } => { + // Destructured `..`-free for the same reason `Effect::ChangeZone` is: a + // new field on `SearchDestinationSplit` (a `rest_enter_tapped`, say) must + // be a COMPILE ERROR here rather than a silently ignored arrival modifier. + // Reading the split by `.field` is exactly how the `Hand` hole survived. + let split_entry_is_confined = split.as_ref().is_none_or( + |SearchDestinationSplit { + primary_destination, + primary_count: _, + primary_enter_tapped, + rest_destination, + }| { + // Both destinations go through the SAME authority as the + // `ChangeZone` arm. `rest_destination` carries no tap state at + // all, so it is asked with `Unspecified` and can never be proven + // tapped — a battlefield `rest_destination` is reach by + // construction, and a hand one is reach because hand always is. + landing_zone_is_confined(*primary_destination, *primary_enter_tapped) + && landing_zone_is_confined(*rest_destination, EtbTapState::Unspecified) + }, + ); + WindowReach::of(target_player.is_none() && split_entry_is_confined) + } + + // CR 400.1: a library is a per-player zone, so shuffling is + // confined exactly when the shuffled player is proven to be the actor. + Effect::Shuffle { target } => WindowReach::of(filter_is_actor_owned(target)), + + // CR 400.1 + CR 400.3 + CR 108.3: a zone change is + // confined when the moved object is proven actor-owned. The second + // disjunct is the anaphoric fetch/ramp shape — `Any` is the serde + // default, i.e. "no filter stated" ("put IT onto the battlefield") — + // which carries no player information of its own. Its player-qualified + // authority is the parent `SearchLibrary`, and the ability-level fold + // is what catches a foreign one: Bribery has the identical + // `Library -> Battlefield / Any` node and still folds to + // `MayInterfere` because its parent search carries + // `target_player: Typed{controller: Opponent}`. + // + // OWNERSHIP IS NOT ENOUGH WHEN THE DESTINATION IS THE BATTLEFIELD. + // CR 110.5b: "Permanents enter the battlefield untapped, unflipped, face + // up, and phased in unless a spell or ability says otherwise." A land + // that arrives UNTAPPED taps for mana in the very window the Shorten + // hands the responder — CR 601.2g runs mana abilities during the cast + // they fund, and CR 302.6's summoning-sickness bar is a CREATURE rule + // that never applies to a land. So an untapped fetch is the + // `Effect::Mana` case below with one extra step, and the reasoning that + // refuses to allowlist mana production refuses this too. MEASURED at + // this base on the live parser: `Terramorphic Expanse[0]` and + // `Evolving Wilds[0]` carry `enter_tapped: Tapped`, while + // `Crop Rotation[0]` and `Nature's Lore[0]` — same anaphoric + // `Library -> Battlefield / Any` node, same absent cost — carry + // `Unspecified` and were allowlisted here until this gate. + // + // Fail-closed on the tap axis: ONLY a provably `Tapped` entry stays + // confined, so `Unspecified` (the serde default, i.e. the AST said + // nothing) and `Untapped` both fall out. A conditionally-untapped entry + // (a shock land's pay-life choice, a land-count gate) cannot be proven + // tapped from the AST and is therefore reach, which is the direction + // this module's §2 default exists to take. + // + // TWO destinations are gated, for the same reason and by different + // evidence. The question this module asks is never "is the moved card + // the actor's own?" — the deleted `Effect::Mana` arm is what happens when + // ownership is mistaken for confinement. It is "can the seat DO something + // with the result inside the window the Shorten hands back?" + // + // * `Battlefield` — an untapped permanent taps for mana during the very + // cast it funds (CR 601.2g), so only a PROVABLY tapped arrival is + // confined. This applies to BOTH disjuncts, not just the anaphoric one: + // proven ownership does not stop an untapped land producing mana, so a + // graveyard-recursion shape reaching the battlefield untapped is the + // same defect through the other door. + // * `Hand` — a card put into hand is a CASTABLE card. When the spell that + // put it there finishes resolving, the active player receives priority + // (CR 117.3b) and priority then passes in turn order (CR 117.3d), so the + // responding seat gets it back still inside this window and can cast it. + // That is the `Effect::Mana` argument with one extra step, exactly as the + // untapped fetch was, and the AST carries nothing that could prove the + // returned card uncastable. MEASURED with the hand gate held off, 327 + // abilities are confined and 176 of them move a card to hand — 166 + // through this arm and 10 through the `SearchLibrary` split, which is + // why both doors now share `landing_zone_is_confined`. The witnesses are + // instant-speed: `Auroral Procession` returns ANY graveyard card, + // including an instant. Gating both doors leaves 151 confined abilities, + // 102 of which reach the battlefield, so the flagship tapped-fetch + // Accept is untouched: this closes a door, it does not vacate the + // feature. + // + // No other destination is gated, and that is a claim about what the seat + // can act with, not an oversight: a move to graveyard, library or exile + // puts the card somewhere the actor cannot cast or activate it from + // inside this window without some FURTHER permission, which would itself + // be an ability this fold already reads. + // + // PROVABLY tapped, not NOMINALLY tapped. Three more of this variant's + // fields decide what actually arrives, and each one is read here rather + // than ignored, because `enter_tapped` alone is not proof: + // + // * `enters_modified_if: Some(filter)` makes the tapped rider + // CONDITIONAL on the moved object's characteristics (CR 614.12: check + // the characteristics of the permanent as it would exist on the + // battlefield; CR 614.12a: any choice it requires is made first), so + // an object that fails the filter enters untapped while the field + // still reads `Tapped`. Unprovable from the AST ⇒ reach. + // * `enters_under` (CR 110.2a: "that object enters the battlefield under + // that player's control unless the effect states otherwise") routes the + // entering permanent to whatever controller it names. Anything but the + // actor puts a permanent on ANOTHER player's board, which is reach by + // definition — tapped or not. + // * `enters_attacking` (CR 508.4) puts the creature into combat against + // a defending player, planeswalker or battle. That is the clearest + // possible touch outside the actor's own resources, and a tapped + // attacker is still an attacker. + // + // A FOURTH battlefield conjunct, on a different axis than the three + // above: `origin == Some(Zone::Library)`. Those three ask what ARRIVES; + // this one asks what the arriving CARD is, and it is the only conjunct + // that can be discharged from this seam's inputs at all. + // + // A permanent entering the battlefield brings its whole printed text + // with it, and `carries_unreadable_rules_content` is this module's + // authority on which of that text the fold cannot classify. For the + // object performing the action, both entry points already run that gate + // (`object_window_reach`, `indexed_ability_window_reach`) — so a + // `target: SelfRef` recursion, where the card that arrives IS the source, + // is already covered by it. For every other target shape the arriving + // card is a DIFFERENT object, and the gate has never been run on it. + // + // `Library` narrows that hole but does NOT close it, and the earlier + // version of this comment claimed it did. It argued that CR 400.2 makes a + // library a hidden zone and the card an unchosen member of it, so "the + // seam is handed a `GameState` and a list of `GameAction`s, and the card + // is the subject of neither, so there is nothing to read". The premise is + // wrong as a fact about this engine. MEASURED on the enrolled 4p dump at + // the exact poll this module decides at: the actor's library is 91 cards + // and ALL 91 resolve in `state.objects` carrying full parsed rules + // content — 5 of them with triggers, and one of those is **Bojuka Bog** + // ("This land enters tapped. / When this land enters, exile target + // player's graveyard. / {T}: Add {B}.", Oracle text verified on + // Scryfall), whose ETB exiles a graveyard the actor does not own. + // CR 701.23a is the rule that makes it reachable: "To search for a card + // in a zone, look at all cards in that zone (EVEN IF IT'S A HIDDEN ZONE) + // and find a card that matches the given description." Hidden means the + // opponents cannot see it; it never meant the searcher cannot choose it. + // + // `board_observer_may_react` genuinely cannot catch this, and for a + // CORRECT reason rather than an omission: CR 113.6, via + // `trigger_definition_functions_in_zone`, answers "does not function" for + // a card sitting in a library. That predicate scans triggers that + // function NOW; the hazard is a trigger that will function once the + // search puts the card onto the battlefield. + // + // So the arriving card is classified where it can be: at the object + // level, by `library_arrivals_are_inert`, which runs + // `carries_unreadable_rules_content` over exactly the cards the actor + // could select. That gate is a board fact, not an AST fact, which is why + // it lives at the two entry points instead of in this arm. + // + // A `Graveyard`, `Hand` or `Exile` origin is a DIFFERENT situation and + // was previously admitted on the same warrant. There the arriving card + // IS in `state.objects`, its rules content is readable, and the fold + // simply never looked — so `OwnResourcesOnly` was asserted over text + // nobody had classified. Helping Hand ("Return target creature card with + // mana value 3 or less from your graveyard to the battlefield tapped.", + // Oracle text verified on Scryfall) read `OwnResourcesOnly` while a + // Fleshbag-Marauder-class creature sat in the actor's graveyard, and + // Accepting it hands back a window in which every player sacrifices. + // + // Gating on `Library` closes that without threading state into an + // AST-only function: it can only move a verdict toward `MayInterfere`, + // per §2 the direction that costs efficacy rather than games, and it is + // the same trade the `Hand` destination gate above already made. + // MEASURED on `data/card-data.json`, over every node in the document + // (not the narrower top-level `abilities` list the fold reads) whose + // shape is a tapped, non-attacking, unconditional, You-controlled + // battlefield entry: 493 total — Library 247, origin ABSENT 140, + // Graveyard 74, Hand 25, Exile 6, Battlefield 1. So the flagship + // library-fetch class keeps half the population outright, and the + // absent-origin bucket falls the same way as the named non-library ones, + // for the stronger version of the same reason: the seam cannot even name + // the zone the card comes from. + Effect::ChangeZone { + origin, + destination, + target, + owner_library: _, + enter_transformed: _, + enters_under, + enter_tapped, + enters_attacking, + up_to: _, + enter_with_counters: _, + conditional_enter_with_counters: _, + face_down_profile: _, + enters_modified_if, + } => { + let object_is_confined = filter_is_actor_owned(target) + || (matches!(target, TargetFilter::Any) + && *origin == Some(Zone::Library) + && *destination == Zone::Battlefield); + let entry_is_confined = landing_zone_is_confined(*destination, *enter_tapped) + && (*destination != Zone::Battlefield + || (*origin == Some(Zone::Library) + && enters_modified_if.is_none() + && !*enters_attacking + && matches!(enters_under, None | Some(ControllerRef::You)))); + WindowReach::of(object_is_confined && entry_is_confined) + } + + // NOT allowlisted — and `Effect::Mana` is the case that has to be NAMED + // here rather than left to the reader, because an earlier revision of + // this module allowlisted it. + // + // CR 106.1: "Mana is the primary resource in the game. Players spend + // mana to pay costs, usually when casting spells and activating + // abilities." CR 106.4 is the rule that earlier arm cited, and it quoted + // only the FIRST sentence — "that mana goes into a player's mana pool". + // The second sentence is the one that refutes the inference drawn from + // it: "From there, it can be used to pay costs immediately". CR 601.2g + // then runs mana abilities during the very cast they fund. + // + // So mana is FUNGIBLE REACH, and the earlier arm's inference — + // "producing it removes nothing from the board" — answers where the mana + // LANDS, which is not what this classifier asks. The question is whether + // the action widens what the seat can do inside the window + // `game::engine`'s `RespondToShortcut(Shorten)` arm hands back. + // + // Whether it does is NOT a function of the AST: it depends on the rest of + // the hand, the board, the colors produced, and on what other permanents + // trigger off mana being added (CR 603.2) or off the source leaving the + // battlefield (CR 701.21a). This function reads ONE ability and no other + // object, so `OwnResourcesOnly` here would be a proof it cannot + // discharge — exactly the unknowable this module's §2 default exists for. + // + // It is also the CONSISTENT answer: stage 1 re-admits a sacrifice-for-mana + // activation precisely BECAUSE the sacrifice is board-changing (see + // `types::mana::ManaSourcePenalty::is_meaningful_priority_activation`), so + // classifying that same action as confined here contradicted the stage + // that handed it over. + _ => WindowReach::MayInterfere, + } +} + +/// Reach of an activation cost. `Sacrifice` is the one cost shape that can name +/// somebody else's permanent, so it reads the same owner axis as the effects. +fn cost_window_reach(cost: &AbilityCost) -> WindowReach { + match cost { + // Tapping the source and spending mana from your own pool (CR 106.4) + // touch nothing outside the actor. + // + // The `Mana` arm here is the SPENDING side and it stays allowlisted; + // only PRODUCING mana is reach. Paying a mana cost consumes a resource + // the actor already holds and hands nothing new to the seat, whereas the + // effect-side `_` arm above explains why adding mana widens what the + // seat can do inside the window. Flipping this arm would classify every + // ability with a mana cost as interference and vacate the feature, so + // its absence from that change is deliberate. + AbilityCost::Tap => WindowReach::OwnResourcesOnly, + AbilityCost::Mana { cost: _ } => WindowReach::OwnResourcesOnly, + AbilityCost::Sacrifice(sacrifice) => { + WindowReach::of(filter_is_actor_owned(&sacrifice.target)) + } + AbilityCost::Composite { costs } => costs + .iter() + .fold(WindowReach::OwnResourcesOnly, |acc, sub| { + acc.or(cost_window_reach(sub)) + }), + _ => WindowReach::MayInterfere, + } +} + +/// Reach of a whole ability, folded over every field that can carry a +/// player-qualified authority. +/// +/// The `..`-free destructure is the structural guard (module doc §2): a new +/// `AbilityDefinition` field is a compile error here until it is classified as +/// walked, conservative-when-present, or reasoned read-free. +fn ability_window_reach(def: &AbilityDefinition) -> WindowReach { + let AbilityDefinition { + // ---- walked ---- + effect, + sub_ability, + else_ability, + mode_abilities, + cost, + player_scope, + // ---- conservative-when-present: each can name a player, an object, or + // a payment outside the actor, and none is walked ---- + activator_filter, + starting_with, + target_chooser, + unless_pay, + distribute, + cost_reduction, + condition, + duration, + multi_target, + target_constraints, + modal, + repeat_for, + announced_x, + repeat_until, + optional_for, + iteration_kind_binding, + // ---- read-free ---- + // Ability class (activated/triggered/static); no player reference. + kind: _, + // Display strings only. + description: _, + target_prompt: _, + // Activation gates: when, from which zone, with which mana, and under + // which keyword this ability may be activated. NOT player-free, and the + // earlier "no player reference" claim here was simply false: an + // `ActivationRestriction::RequiresCondition` carries a `ParsedCondition` + // that can name a player, and several do ("Activate only if an opponent + // lost life this turn"). Read-free anyway, which is the load-bearing + // part: a restriction narrows WHEN an ability may be activated, never + // WHAT it reaches. It contributes no effect, no target and no payment, + // so it can only make a window rarer — never wider. + activation_restrictions: _, + activation_mana_payment_restriction: _, + activation_zone: _, + ability_tag: _, + // Booleans and scalars that gate shape, never a player or an object. + optional_targeting: _, + optional: _, + target_choice_timing: _, + min_x_value: _, + cant_be_copied: _, + forward_result: _, + target_selection_mode: _, + sub_link: _, + sibling_condition: _, + } = def; + + let mut acc = effect_window_reach(effect); + if let Some(sub) = sub_ability { + acc = acc.or(ability_window_reach(sub)); + } + if let Some(other) = else_ability { + acc = acc.or(ability_window_reach(other)); + } + for mode in mode_abilities { + acc = acc.or(ability_window_reach(mode)); + } + if let Some(cost) = cost { + acc = acc.or(cost_window_reach(cost)); + } + // CR 400.1: an untargeted mass effect scoped to anyone but the actor + // reaches another player's resources by construction. + if let Some(scope) = player_scope { + acc = acc.or(WindowReach::of(matches!(scope, PlayerFilter::Controller))); + } + + let conservative_when_present = activator_filter.is_some() + || starting_with.is_some() + || target_chooser.is_some() + || unless_pay.is_some() + || distribute.is_some() + || cost_reduction.is_some() + || condition.is_some() + || duration.is_some() + || multi_target.is_some() + || !target_constraints.is_empty() + || modal.is_some() + || repeat_for.is_some() + || announced_x.is_some() + || repeat_until.is_some() + || optional_for.is_some() + || iteration_kind_binding.is_some(); + acc.or(WindowReach::of(!conservative_when_present)) +} + +/// Does this object carry rules content this module cannot classify? +/// +/// Single authority for that question — both entry points route through it, because +/// the previous shape (each entry point spelling out its own three-field check) is how +/// two answers to one question drift apart, and this module has already paid for that +/// once with `landing_zone_is_confined`. +/// +/// The list is deliberately EVERY rules-bearing field `game::printed_cards` writes and +/// `ability_window_reach` cannot read, not a curated subset of the ones known to be +/// dangerous. A curated subset is an allowlist maintained by whoever remembers to +/// update it; MEASURED, the previous three-field version claimed in its own doc comment +/// to cover what `printed_cards` splits a face into, and was wrong by eight fields. +/// `obj.keywords` alone carries printed **Cascade** (`game::triggers`: printed Cascade +/// lives in `obj.keywords` and never reaches `trigger_definitions`), so a Cascade spell +/// whose printed `abilities` all read confined would have been proven +/// `OwnResourcesOnly` while resolving it casts a free spell of arbitrary reach inside +/// the very window the seat just declined to keep. +/// +/// MEASURED cost of widening from three fields to all of them, on this candidate's own +/// projection: of the 53 cards that survived the three-field gate, 10 now flip (8 on +/// `keywords`, 1 `modal`, 1 `additional_cost`), leaving 43. The class this classifier +/// exists to protect is untouched — Terramorphic Expanse, Evolving Wilds and Rampant +/// Growth carry none of these fields. +/// +/// A presence gate is conservative by construction: it can only move a verdict toward +/// `MayInterfere`, which per §2 is the direction that costs efficacy rather than games. +fn carries_unreadable_rules_content(object: &GameObject) -> bool { + !object.trigger_definitions.is_empty() + || !object.replacement_definitions.is_empty() + || !object.static_definitions.is_empty() + // Keywords are rules text the fold never sees; Cascade is the sharp case. + || !object.keywords.is_empty() + // Casting-time modifiers: each one changes what resolving the object does or + // what the seat may do with it, and none is an `AbilityDefinition`. + || object.modal.is_some() + || object.additional_cost.is_some() + || object.strive_cost.is_some() + || object.cleave_variant.is_some() + || !object.casting_restrictions.is_empty() + || !object.casting_options.is_empty() + || !object.spellbook.is_empty() + // A back face is an entire second face of rules content that this fold never + // descends into. 0 carriers among today's confined set, so gating it is free. + || object.back_face.is_some() + // The four below were found by WRITING the staleness guard below, not by reading + // this list again — which is the argument for the guard existing. All four are + // rules-bearing, all four are written by `printed_cards`, and none was gated. + // MEASURED at this candidate's projection: each is 0 among the cards that survive + // the gate, and real document-wide (solve conditions 15, Class 38, Case 15, + // Attraction 35), so they are latent holes rather than live ones — the same shape + // as the `Zone::Stack` arm, and closed for the same reason. + || object.case_state.is_some() + || object.class_level.is_some() + || object.intensity != 0 + || !object.attraction_lights.is_empty() + // CR 709.5: a shared type line is two static abilities that remove the name, mana + // cost and RULES TEXT of each locked half; CR 709.5c names the unlocked + // designations, and CR 709.5e lets any player unlock a half as a special action at + // any priority. So which halves are unlocked decides what rules text the permanent + // has, `obj.abilities` is a flat list that cannot express that, and an opponent can + // change the answer inside the very window this fold is deciding about. + // Found by the same review round that caught the `card_type` bucket below, and it + // is the fourth subtype-derived field of four — the other three were already here, + // which is what made this one's absence a curated subset rather than a set. + || object.room_unlocks.is_some() + // THE PARSER SAID IT COULD NOT READ THIS CARD. Every other disjunct above gates a + // field whose CONTENTS this fold cannot classify; this one gates the parser's own + // report that some printed clause never became a field at all. That is the exact + // residual the module doc's §2 names as the one thing the fail-closed default + // cannot cover — "a clause the PARSER silently swallows never becomes a shape here, + // so the ability this module is handed looks strictly MORE confined than the + // printed card is" — and a diagnostic is the seat's only in-band evidence that it + // happened. Reading `abilities` and ignoring the note saying `abilities` is + // incomplete is proving confinement from the fraction of the card that parsed. + || !object.parse_warnings.is_empty() +} + +/// Is every card the actor could SELECT out of a hidden zone and put onto the +/// battlefield provably inert? +/// +/// The companion to [`carries_unreadable_rules_content`], on the other object. +/// That gate asks "can this fold read the rules content of the card TAKING the +/// action"; this one asks the same question about the card the action would +/// BRING IN. `effect_window_reach`'s `ChangeZone` arm admits a battlefield +/// arrival only from `Zone::Library`, and its own comment used to discharge the +/// arriving card's text with a hidden-zone argument. CR 701.23a refutes it: "To +/// search for a card in a zone, look at all cards in that zone (even if it's a +/// hidden zone) and find a card that matches the given description." A hidden +/// zone hides the cards from the OPPONENTS; the searcher picks whichever member +/// they like, so the selectable set is readable and this fold has to read it. +/// +/// # Why this is a board fact and not an AST fact +/// +/// It lives here, called from the two entry points beside +/// `carries_unreadable_rules_content`, rather than inside `effect_window_reach`. +/// The reason is not convenience: the answer is not a function of the ability at +/// all. Two players holding the identical printed card get opposite verdicts, +/// because the verdict is about what is in the deck behind it. Threading a +/// `GameState` into an AST classifier would present a board-dependent answer as +/// an AST-dependent one. +/// +/// # The selection set, and the direction each approximation errs +/// +/// * **Which cards are in the pool** — every object the actor OWNS in a pooled +/// zone. `game::effects::search_library` reads `owner.library` and matches with +/// `matches_target_filter_in_owner_zone`, so owner (CR 108.3), not controller, +/// is the axis, and this scans `state.objects` for the same set. The pool is +/// `Zone::Library` plus whatever `Effect::SearchLibrary::source_zones` names — +/// the God-Pharaoh's-Gift class searches graveyard and hand as well, and a card +/// fetched from there reaches the same battlefield. +/// * **Which of them are selectable** — the union of every `SearchLibrary` +/// filter the object carries, plus every library→battlefield `ChangeZone` +/// target that is not `TargetFilter::Any`. A UNION over the object's searches +/// rather than a binding of each search to the node that consumes its result: +/// binding would require threading a "search filter currently in scope" through +/// the whole recursive fold, and a union is a SUPERSET of any single search's +/// result, so it errs toward `MayInterfere`. `TargetFilter::Any` is excluded +/// because it is the serde default on the ANAPHORIC node ("put IT onto the +/// battlefield") — it states no restriction of its own and its authority is the +/// parent search, which is already in the union. An object with a +/// library→battlefield arrival and NO search filter at all leaves the union +/// empty, and an empty union means the WHOLE pool is selectable. +/// * **`selection_constraint` and `game::effects::search_library`'s +/// `library_search_top_limit` are ignored.** Both NARROW what may be chosen, so +/// ignoring them over-counts the pool, which is the conservative direction. +/// +/// # The one place this fails closed rather than answering +/// +/// A non-empty union that matches NOTHING returns `false`. That looks like +/// throwing away the free case — a deck with no basic land for its own fetch — +/// and it is deliberate, because this seam cannot tell that case apart from a +/// filter it is unable to evaluate. `FilterContext::from_source_with_controller` +/// carries the acting object but no ability instance, so a filter that reads the +/// ability's own targets matches nothing here for a reason that has nothing to do +/// with the deck. MEASURED over `data/card-data.json`: 24 cards carry such a +/// `SearchLibrary` filter (23 `SameNameAsParentTarget` — Infernal Tutor, Surgical +/// Extraction, Bifurcate, … — and 2 `CanEnchant { target: ParentTarget }`: +/// Auratouched Mage, Sovereigns of Lost Alara, with Canoptek Wraith carrying +/// both properties). Answering "empty match set, therefore inert" for those would +/// be a false `Accept` produced by an unevaluated filter, so the empty result is +/// treated as the unanswerable it is. +fn library_arrivals_are_inert( + state: &GameState, + object: &GameObject, + abilities: &[AbilityDefinition], +) -> bool { + let mut reaches_battlefield = false; + let mut pool_zones = vec![Zone::Library]; + let mut selection: Vec = Vec::new(); + + // `types::ability_visit` is the engine's single complete `AbilityDefinition` + // walk, and its `Effect` match is wildcard-free — a future variant carrying a + // nested effect is a compile error there rather than a silent miss here. A + // hand-rolled second walk beside `ability_window_reach`'s is exactly the + // "two answers to one question" shape this module has already paid for twice + // (`landing_zone_is_confined`, `carries_unreadable_rules_content`). + for def in abilities { + // The visitor never breaks — the union needs every node — so the + // `ControlFlow` it returns is always `Continue`. + let _ = crate::types::ability_visit::visit_ability_def(def, &mut |effect: &Effect| { + match effect { + // `..`-FREE, exactly like `effect_window_reach`'s two arms and for + // the same reason: a new field that changes what a search may FIND + // or where it may PUT it must be a compile error here, not a + // silently unread modifier. `count`/`reveal`/`selection_constraint` + // are bound and unused deliberately — the first two do not change + // WHICH cards match, and the third only narrows (see the doc). + Effect::SearchLibrary { + source_zones, + filter, + count: _, + reveal: _, + target_player: _, + selection_constraint: _, + split, + } => { + selection.push(filter.clone()); + for zone in source_zones { + if !pool_zones.contains(zone) { + pool_zones.push(*zone); + } + } + // Both split destinations, for the same reason the reach fold + // reads both: `rest_destination` lands cards too. + if split.as_ref().is_some_and(|split| { + split.primary_destination == Zone::Battlefield + || split.rest_destination == Zone::Battlefield + }) { + reaches_battlefield = true; + } + } + // The arrival-shape fields are the REACH fold's business; this arm + // only needs to know that a library card can land on the + // battlefield and which filter picks it. + Effect::ChangeZone { + origin: Some(Zone::Library), + destination: Zone::Battlefield, + target, + owner_library: _, + enter_transformed: _, + enters_under: _, + enter_tapped: _, + enters_attacking: _, + up_to: _, + enter_with_counters: _, + conditional_enter_with_counters: _, + face_down_profile: _, + enters_modified_if: _, + } => { + reaches_battlefield = true; + if !matches!(target, TargetFilter::Any) { + selection.push(target.clone()); + } + } + // A node this walk does not recognize contributes nothing to the + // pool, and that is safe only because it is not the last word: + // `effect_window_reach`'s own `_` arm answers `MayInterfere` for + // every unrecognized shape, so a future effect that could put a + // library card onto the battlefield makes the WHOLE verdict + // interference before this pool is ever consulted. + _ => {} + } + std::ops::ControlFlow::Continue(()) + }); + } + + if !reaches_battlefield { + return true; + } + + let actor = object.controller; + let pool: Vec<&GameObject> = state + .objects + .values() + .filter(|candidate| candidate.owner == actor && pool_zones.contains(&candidate.zone)) + .collect(); + // Nothing can be selected out of an empty pool, so nothing arrives. This is + // the one empty set that IS an answer, and it is the opposite of the empty + // match set below: here the filter never ran. + if pool.is_empty() { + return true; + } + + let context = crate::game::filter::FilterContext::from_source_with_controller(object.id, actor); + let selectable: Vec<&&GameObject> = if selection.is_empty() { + pool.iter().collect() + } else { + pool.iter() + .filter(|candidate| { + selection.iter().any(|filter| { + crate::game::filter::matches_target_filter_in_owner_zone( + state, + candidate.id, + filter, + &context, + ) + }) + }) + .collect() + }; + if selectable.is_empty() { + return false; + } + selectable + .iter() + .all(|candidate| !carries_unreadable_rules_content(candidate)) +} + +/// Fold every ability an object carries. A missing object, or one carrying no +/// abilities at all, is `MayInterfere` — the fail-closed direction, and the one +/// that keeps an empty fold from being proven confined by its identity element. +/// +/// `abilities` is NOT the whole of an object's rules content. `game::printed_cards` +/// spreads a card face across many more rules-bearing fields than this module can +/// classify — `carries_unreadable_rules_content` below is the enumerated authority on +/// which, and the one place to look; a count written here would only drift out of step +/// with it. This module classifies exactly one of them, because `ability_window_reach` +/// destructures an `AbilityDefinition` and the rest carry different types entirely. +/// Folding only `abilities` and returning `OwnResourcesOnly` would therefore prove a +/// card confined from whichever fraction of it happens to be ability-shaped. +/// +/// That is not hypothetical. MEASURED on `data/card-data.json`: `Stunning Reversal` +/// projects `abilities` = one `ChangeZone { destination: Exile, target: SelfRef }` +/// — actor-owned, non-battlefield, hence confined on every conjunct this module +/// reads — while its entire function lives in `replacements[0]`, a `GameLoss` +/// replacement ("The next time you would lose the game this turn, instead draw +/// seven cards and your life total becomes 1"). A seat holding it would have read +/// `OwnResourcesOnly` and Accepted the very shortcut the card exists to survive. +/// Per this module's §2 that is the losing direction, and it is the same defect +/// class as the deleted `Effect::Mana` arm: reasoning from the part of the card the +/// classifier can see instead of from what the seat can do. +/// +/// So a non-empty unreadable field is `MayInterfere` — not because its contents are +/// known to interfere, but because this module cannot prove they do not, which is the +/// only warrant `OwnResourcesOnly` ever has. Reading them properly (classifying +/// `TriggerDefinition` / `ReplacementDefinition` / `StaticDefinition` the way +/// `ability_window_reach` classifies an `AbilityDefinition`) is the named upgrade +/// path; until then the gate is presence, and presence is conservative. +fn object_window_reach(state: &GameState, object_id: ObjectId) -> WindowReach { + let Some(object) = state.objects.get(&object_id) else { + return WindowReach::MayInterfere; + }; + if object.abilities.is_empty() { + return WindowReach::MayInterfere; + } + if carries_unreadable_rules_content(object) { + return WindowReach::MayInterfere; + } + if !library_arrivals_are_inert(state, object, &object.abilities) { + return WindowReach::MayInterfere; + } + object + .abilities + .iter() + .fold(WindowReach::OwnResourcesOnly, |acc, ability| { + acc.or(ability_window_reach(ability)) + }) +} + +/// Reach of one indexed activated ability. An unresolvable object or an +/// out-of-range index is `MayInterfere`. +/// +/// Carries the same unreadable-content gate as `object_window_reach`, and for a +/// reason specific to this path rather than by symmetry: activating an ability is +/// itself a game event, so a trigger on the SAME object can fire off the activation +/// (CR 603.2) or off the cost being paid, and a static ability can change what the +/// activation is allowed to do. This module cannot classify any of that, so an object +/// carrying it is not provably confined no matter how confined the indexed ability +/// reads on its own. +fn indexed_ability_window_reach( + state: &GameState, + source_id: ObjectId, + ability_index: usize, +) -> WindowReach { + let Some(object) = state.objects.get(&source_id) else { + return WindowReach::MayInterfere; + }; + if carries_unreadable_rules_content(object) { + return WindowReach::MayInterfere; + } + let Some(ability) = object.abilities.get(ability_index) else { + return WindowReach::MayInterfere; + }; + // Scoped to the ONE ability being activated, unlike the object-level entry + // point: a sibling ability the seat is not activating fetches nothing. + if !library_arrivals_are_inert(state, object, std::slice::from_ref(ability)) { + return WindowReach::MayInterfere; + } + ability_window_reach(ability) +} + +/// CR 603.2: can a CONFINED action's event stream ever match this trigger's +/// trigger event? Returns `true` iff it PROVABLY cannot. +/// +/// # This predicate is NOT a fact about the trigger +/// +/// It is a fact about the trigger RELATIVE TO the confined-action allowlists this +/// module defines, and it lives here for exactly that reason. Relief is not +/// intrinsic to a `TriggerMode`; every arm below is discharged by reading what +/// [`cost_window_reach`] and [`effect_window_reach`] admit, so the premise has to +/// live where those allowlists live. Moving it to `game::triggers` would present +/// it as a CR-grounded property of a trigger and invite reuse by a caller with a +/// different action set, for which every arm below is unproven. +/// +/// ## The soundness contract, named in full +/// +/// Relief holds only while BOTH allowlists stay exactly as they are today: +/// +/// * [`cost_window_reach`] admits `AbilityCost::Tap`, `AbilityCost::Mana`, +/// an actor-owned `AbilityCost::Sacrifice`, and `AbilityCost::Composite` of +/// those. Everything else is `MayInterfere`. +/// * [`effect_window_reach`] admits `Effect::SearchLibrary`, `Effect::Shuffle` +/// and a gated `Effect::ChangeZone`. Everything else is `MayInterfere`. +/// +/// **WIDEN EITHER ALLOWLIST AND YOU MUST RE-AUDIT EVERY ARM BELOW.** The witness +/// that makes that concrete, and the reason the Life family's relief is +/// CONTINGENT rather than structural: `AbilityCost::PayLife` exists +/// (`types::ability`) and a Polluted-Delta-class fetchland pays it — "{T}, Pay 1 +/// life, Sacrifice this land: Search your library …" — squarely inside this +/// predicate's own event boundary, which counts cost payment. Such a card is +/// `MayInterfere` today ONLY because `PayLife` falls to `cost_window_reach`'s +/// fail-closed `_` arm. Allowlist `PayLife` and a Bloodthirsty Conqueror +/// ("Whenever an opponent loses life, you gain that much life") fires through a +/// mode this function has RELIEVED — which is the precise defect the board scan +/// exists to prevent. +/// +/// ## Boundary +/// +/// The classified action set is `GameAction::CastSpell` and +/// `GameAction::ActivateAbility` only; every other `GameAction` already returns +/// `true` in [`any_action_may_interfere`] without consulting this. Events are +/// counted from announcement through full resolution, INCLUDING cost payment +/// (CR 601.2b–i for a spell, CR 602.2b for an activated ability, which routes to +/// the same process). `GameAction::PassPriority` is excluded by construction (it +/// returns `false` before the fold), so phase advance is out of domain and a +/// beginning-of-phase trigger cannot be reached through this predicate's callers. +/// +/// ## Fail-closed +/// +/// Exhaustive dispatch with a `_ => false` arm, mirroring +/// [`crate::game::triggers::trigger_event_unreachable_in_phase`]: a mode this +/// predicate cannot classify KEEPS its veto. A future mode is swallowed into +/// conservatism, never into relief. +fn trigger_event_unreachable_by_confined_action( + def: &crate::types::ability::TriggerDefinition, +) -> bool { + use crate::types::triggers::TriggerMode; + + match def.mode { + // LIFE — CR 119.3: "If an effect causes a player to gain life or lose + // life, that player's life total is adjusted accordingly." No allowlisted + // effect adjusts a life total, and no allowlisted COST pays life. See the + // `AbilityCost::PayLife` witness above: this family is the contingent one. + // `PayEcho`/`PayCumulativeUpkeep` additionally route to `match_phase` + // (`game::trigger_matchers`), i.e. they key on `GameEvent::PhaseChanged`, + // which the turn-structure argument below covers independently. + TriggerMode::LifeGained + | TriggerMode::LifeLost + | TriggerMode::LifeLostAll + | TriggerMode::LifeChanged + | TriggerMode::PayLife + | TriggerMode::PayCumulativeUpkeep + | TriggerMode::PayEcho => true, + + // DAMAGE — CR 120.1: "Objects can deal damage to battles, creatures, + // planeswalkers, and players." Searching, shuffling and moving a card + // deal no damage, and no allowlisted cost does either. `Fight`/`FightOnce` + // are in this family by CR 701.14a (each fighting creature "deals damage equal to its + // power to the other creature") and are + // additionally keyed on `EffectKind::Fight` in `game::trigger_matchers`, + // which no allowlisted effect produces. + TriggerMode::DamageDone + | TriggerMode::DamageDoneOnce + | TriggerMode::DamageAll + | TriggerMode::DamageDealtOnce + | TriggerMode::DamageDoneOnceByController + | TriggerMode::DamageReceived + | TriggerMode::DamagePreventedOnce + | TriggerMode::ExcessDamage + | TriggerMode::ExcessDamageAll + | TriggerMode::Fight + | TriggerMode::FightOnce => true, + + // COMBAT — CR 508.1 and CR 509.1 both open "this turn-based action doesn't + // use the stack": attackers and blockers are declared by the turn-based + // actions of CR 506.1's declare-attackers and declare-blockers steps, not + // by any spell or ability. MEASURED in `game::trigger_matchers`: every mode + // below keys on `GameEvent::AttackersDeclared` or + // `GameEvent::BlockersDeclared`, neither of which a cast or an activation + // emits. + // + // The one allowlisted effect that could otherwise put a creature into + // combat is `Effect::ChangeZone`'s `enters_attacking` rider (CR 508.4), + // and `effect_window_reach` already requires `!enters_attacking` for a + // battlefield arrival to be confined — so this family depends on that + // conjunct and not merely on the turn-based-action argument. + // + // `TriggerMode::EntersOrAttacks` is DELIBERATELY ABSENT — see the excluded + // list below. + TriggerMode::Attacks + | TriggerMode::AttackersDeclared + | TriggerMode::AttackersDeclaredOneTarget + | TriggerMode::YouAttack + | TriggerMode::YouAttackUnblocked + | TriggerMode::AttackerBlocked + | TriggerMode::AttackerBlockedOnce + | TriggerMode::AttackerBlockedByCreature + | TriggerMode::AttackerUnblocked + | TriggerMode::AttackerUnblockedOnce + | TriggerMode::Blocks + | TriggerMode::BlockersDeclared + | TriggerMode::BecomesBlocked + | TriggerMode::AttacksOrBlocks + | TriggerMode::BlocksOrBecomesBlocked => true, + + // TURN STRUCTURE — CR 500.1: "A turn consists of five phases, in this + // order …". A phase or turn begins by turn-based action, never because a + // player cast a spell or activated an ability. `PassPriority` is the + // action that CAN advance a phase and it never reaches this predicate + // (see Boundary above). + TriggerMode::Phase | TriggerMode::TurnBegin | TriggerMode::NewGame => true, + + // CARD FLOW the allowlist cannot cause. + // + // * `Drawn` — CR 121.1: "A player draws a card by putting the top card of + // their library into their hand." Doubly unreachable: `match_drawn` keys + // on the dedicated `GameEvent::CardDrawn`, AND no confined action can put + // a card into a hand at all, because `landing_zone_is_confined` answers + // `false` for `Zone::Hand` on both doors out of a library. + // * `Discarded`/`DiscardedAll` — CR 701.9a: "To discard a card, move it + // from its owner's hand to that player's graveyard." `match_discarded` + // keys on the dedicated `GameEvent::Discarded`, which + // `game::zone_pipeline` emits only for a recorded discard, not for a + // generic hand-to-graveyard move. + // * `TokenCreated`/`TokenCreatedOnce` — CR 111.1: tokens are put onto the + // battlefield by effects that say so. `Effect::Token` is not allowlisted, + // and `match_token_created` keys on the dedicated + // `GameEvent::TokenCreated`. + // + // `Milled`/`MilledOnce`/`MilledAll` are DELIBERATELY ABSENT — see below. + TriggerMode::Drawn + | TriggerMode::Discarded + | TriggerMode::DiscardedAll + | TriggerMode::TokenCreated + | TriggerMode::TokenCreatedOnce => true, + + // ── NOT RELIEVED. Each keeps its veto, with the source that reaches it. ── + // + // Directly produced by casting or activating: + // `SpellCast` CR 601.2i — announcing the spell IS the event + // `AbilityActivated` CR 602.2b — likewise for an activation + // `Taps` / `TapsForMana` / `ManaAdded` — `AbilityCost::Tap` and the mana + // the actor spends + // `PlayerPerformedAction` — `game::search_library` emits it + // `SearchedLibrary` — `Effect::SearchLibrary` itself + // `Shuffled` — `Effect::Shuffle` itself + // Produced by the allowlisted `ChangeZone` / `Sacrifice`: + // `Exiled`, `Sacrificed`, `Destroyed`, `ChangesZone`, `ChangesZoneAll`, + // `LeavesBattlefield`, `Revealed`, `BecomesTarget`, `CounterAdded` + // (`game::zone_pipeline` puts counters on an entering permanent), + // `ChangesController`, `Attached`, `Unattach`. + // Unknowable here: + // `StateCondition` — CR 603.2 state triggers watch a game STATE, not an + // event, so no event-stream argument can relieve one; + // `Unknown(_)` — an unclassified Forge mode string, by definition. + // + // THREE MEASURED EXCLUSIONS from families that otherwise look relieved. + // Each was found by reading the matcher rather than the mode name, and + // each reads a GENERIC `GameEvent::ZoneChanged` that an allowlisted + // `Effect::ChangeZone` really does emit: + // + // * `Milled` / `MilledOnce` / `MilledAll` — CR 701.17a defines milling as + // library-to-graveyard, and `game::trigger_matchers::match_milled` keys + // on `ZoneChanged { from: Some(Library), to: Graveyard }` rather than on + // any mill-specific event. `Effect::ChangeZone { origin: Some(Library), + // destination: Graveyard, target: }` is confined here + // (graveyard is a confined landing zone, the target proves control) and + // emits exactly that event, so relieving the family would be unsound. + // * `EntersOrAttacks` — `match_enters_or_attacks` reads `ZoneChanged`, so + // the flagship's own tapped fetch fires it. It is a combat mode by name + // only. + // * `EntersOrHauntedCreatureDies` — dispatches to `match_changes_zone` + // outright (`game::trigger_matchers`). + // + // Fail-closed: every mode this predicate cannot classify keeps its veto. + _ => false, + } +} + +/// CR 603.2: could ANY trigger anywhere on the board fire off a confined action +/// the actor takes in this window? +/// +/// [`any_action_may_interfere`]'s per-action fold reads the acting object's own +/// AST and nothing else, so it cannot see an OBSERVER — a permanent belonging to +/// somebody else whose triggered ability watches for the very event the confined +/// action produces. The maintainer witness is Hedron Crab: "Landfall — Whenever a +/// land you control enters, target player mills three cards." A seat that +/// confidently cracks its own fetchland in front of an opponent's Crab has just +/// milled a player, and no amount of reading the fetchland proves otherwise. +/// +/// Three INDEPENDENT SUFFICIENT reliefs, applied as a DISJUNCTION — a trigger is +/// inert iff at least one holds. They are ordered cheapest-first, and the zone +/// gate MUST stay first: MEASURED on the largest board in the enrolled fixture +/// set it removes 103 of 145 candidate definitions before any other work runs. +/// +/// 1. **Zone of function** — CR 113.6 / CR 603.6: +/// [`crate::game::triggers::trigger_definition_functions_in_zone`] is the +/// single authority. Reading `def.trigger_zones.contains(&zone)` directly is +/// FAIL-OPEN and must never be done here: an EMPTY `trigger_zones` means +/// battlefield-only, so a direct `contains` answers "does not function" for +/// every ordinary battlefield trigger on the board. +/// 2. **Mode** — [`trigger_event_unreachable_by_confined_action`]. +/// 3. **The self-reference carve-out** — a trigger that watches only ITS OWN +/// object (CR 109.5: "you"/"your" refer to the object's controller) and whose +/// object the actor does not own cannot be reached by an action confined to +/// the actor's own resources. All three conjuncts are load-bearing: +/// * `zone_change_clauses.is_empty()`, because a non-empty clause list makes +/// the matcher IGNORE the scalar `valid_card` entirely +/// (`types::ability::TriggerDefinition`), so reading `valid_card` under a +/// disjunctive trigger reads a field the engine does not consult; +/// * `valid_card == Some(TargetFilter::SelfRef)` EXACTLY. This deliberately +/// does NOT reuse [`filter_is_actor_owned`], which answers `true` for +/// `Typed { controller: Some(You) }` — precisely Hedron Crab's `valid_card`, +/// so reusing it would carve out the very trigger this scan exists to catch. +/// `valid_card: None` means UNRESTRICTED rather than self +/// (`game::trigger_index`), and `== Some(SelfRef)` rejects it without a +/// dedicated arm; +/// * `obj.owner != actor`, because the actor's OWN self-referential observer +/// is reachable by the actor's own action. +/// +/// NOT a `TriggerDefinition` destructure, unlike [`ability_window_reach`]'s +/// `..`-free discipline, and the asymmetry is deliberate. The 30 fields not read +/// here are inert, effect-side, or NARROWING — a narrowing field can only make a +/// trigger fire less often, so ignoring it is conservative. The two that can +/// broaden a trigger within its class, `origin_zones` and `zone_change_clauses`, +/// belong exclusively to zone-change modes, and no zone-change mode is ever +/// relieved by arm 2 (`ChangesZone`/`ChangesZoneAll` fall to `_ => false`); +/// `zone_change_clauses` is additionally read directly by arm 3. +/// +/// CR 603.10a: this reads live `state.objects` rather than any look-back +/// snapshot, which is correct here because the question is prospective — what +/// could fire if the actor acts — not what did fire. +fn board_observer_may_react(state: &GameState, actor: PlayerId) -> bool { + state.objects.values().any(|obj| { + crate::game::functioning_abilities::active_trigger_definitions(state, obj).any(|active| { + let def = active.definition; + let inert = !crate::game::triggers::trigger_definition_functions_in_zone(def, obj.zone) + || trigger_event_unreachable_by_confined_action(def) + || (def.zone_change_clauses.is_empty() + && def.valid_card == Some(TargetFilter::SelfRef) + && obj.owner != actor); + !inert + }) + }) +} + +/// Stage 2's predicate: does the polled player hold any action that could reach +/// past their own resources? +/// +/// Reads the actions stage 1 counted — no clone, no `find_legal_targets`, no +/// simulation, no graph build. The caller MUST hand it +/// `ai_support::stage_two_action_set`, not the bare flat list: stage 1 counts +/// sacrifice-for-mana activations that only ever live in +/// `legal_actions_by_object`, and an action never handed to this fold reaches no +/// arm at all, so the fail-closed `_ => MayInterfere` default cannot protect it. +/// Missing an action here is an Accept by OMISSION — the direction that loses +/// games. +/// +/// # What this seam's inputs can and cannot describe +/// +/// A fetched permanent that itself enables interference is modelled only through +/// its ARRIVAL STATE, never by walking its abilities. `effect_window_reach` keeps +/// a battlefield entry confined only when the AST proves it arrives tapped +/// (CR 110.5b), so the mana axis — the fetched land that taps for mana inside +/// this same window — is closed: an untapped entry reads `MayInterfere`. +/// +/// What that leaves outside the seam's description, stated as a property of the +/// inputs rather than as a blanket: a permanent that arrives TAPPED and still +/// enables interference through an ability whose cost is not tapping (a +/// sacrifice-for-mana, say). +/// +/// That residual is scoped to ONE origin, and the scoping is enforced in code +/// rather than promised here. `effect_window_reach`'s `ChangeZone` arm admits a +/// battlefield entry only when `origin == Some(Zone::Library)`, and what the +/// arriving card can DO is then classified by [`library_arrivals_are_inert`], +/// which both entry points run. +/// +/// The residual is what THAT gate leaves, and it is narrower than the whole +/// fetch class: `carries_unreadable_rules_content` is a presence check, so a +/// selectable card with no trigger, replacement, static or keyword passes it +/// while still carrying an ACTIVATED ability whose cost is not tapping. Such a +/// card arrives tapped (the arm requires it) and can still be sacrificed for +/// value inside this window. +/// +/// This paragraph previously claimed the residual was total and unreachable, on +/// the argument that CR 400.2 makes the library hidden so "neither input +/// contains it and neither the per-action fold nor the board scan has an object +/// to read". That was measurably false — the enrolled 4p dump resolves all 91 of +/// the actor's library cards in `state.objects` — and CR 701.23a is the rule it +/// mistook: a search looks at all cards in the zone "even if it's a hidden +/// zone". `board_observer_may_react` really does miss them, but for the CR 113.6 +/// reason (`trigger_definition_functions_in_zone` answers "does not function" for +/// a library card), which is why the selection gate is separate from it. +/// +/// A `Graveyard`, `Hand` or `Exile` origin is a different situation again — the +/// arriving card is named by the effect's own target rather than chosen out of a +/// pool — and it is not in this residual because the arm no longer admits it. See +/// that arm's fourth battlefield conjunct for the measurement and the efficacy +/// trade. +/// +/// Its cost, on both axes, for the reader deciding whether that matters: +/// - across windows: a bounded miss — a seat's fetched answer goes unused for +/// THIS shortcut; +/// - within the window: NOT bounded by "one shortcut". On an `UntilLethal` +/// offer the accepted sequence runs to lethal, so the in-window cost of a +/// missed out is elimination. +/// +/// The miss requires the out to be reachable ONLY through the fetched permanent +/// AND that permanent to arrive tapped; a directly-castable answer is already +/// caught by the top-level fold, and CR 732.1b makes shortcut use permissive +/// ("can be used") rather than guaranteed. +/// +/// # Two board-level conjuncts, evaluated ONCE +/// +/// Both run BEFORE the action fold, because each is a fact about the BOARD and +/// not about any action: re-deriving them per action would multiply a whole-board +/// scan by the action count and produce the identical answer every time. +/// +/// * [`actor_owns_everything_they_control`] — the fold's filter proofs are +/// CONTROL proofs (CR 109.5), and CR 701.21a routes a sacrificed permanent to +/// its OWNER's graveyard. +/// * [`board_observer_may_react`] — a confined action is still OBSERVED +/// (CR 603.2); an opponent's Hedron Crab turns the actor's own fetch into a +/// mill targeting somebody else. +pub(crate) fn any_action_may_interfere( + state: &GameState, + actor: PlayerId, + actions: &[GameAction], +) -> bool { + if !actor_owns_everything_they_control(state, actor) { + return true; + } + if board_observer_may_react(state, actor) { + return true; + } + actions.iter().any(|action| match action { + GameAction::PassPriority => false, + GameAction::CastSpell { object_id, .. } => { + object_window_reach(state, *object_id).may_interfere() + } + GameAction::ActivateAbility { + source_id, + ability_index, + } => indexed_ability_window_reach(state, *source_id, *ability_index).may_interfere(), + // Every other priority action (play a land, declare attackers, special + // actions, ...) is unclassified and therefore interference. + _ => true, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::game::zones::create_object; + use crate::parser::oracle::parse_oracle_text; + use crate::types::card::{CardFace, CardMetadata}; + use crate::types::keywords::Keyword; + + /// A card as the pipeline sees it: its REAL printed name plus its verbatim + /// Oracle text. The name is load-bearing, not decoration — `~` + /// normalization resolves a card's self-reference by matching its own name, + /// so parsing "Lightning Bolt deals 3 damage to any target." under a + /// placeholder name yields `Effect::Unimplemented` and the row would then + /// be measuring the fail-closed arm instead of the effect's own semantics + /// (MEASURED: that is exactly what an earlier revision of this table did). + struct Card { + name: &'static str, + oracle: &'static str, + } + + /// Parse verbatim Oracle text the way the card pipeline does and return the + /// ability at `index`. + fn ability(card: &Card, index: usize) -> AbilityDefinition { + let parsed = parse_oracle_text(card.oracle, card.name, &[], &[], &[]); + parsed + .abilities + .get(index) + .unwrap_or_else(|| { + panic!( + "{}[{index}] must exist; parsed {} abilities", + card.name, + parsed.abilities.len() + ) + }) + .clone() + } + + fn reach(card: &Card, index: usize) -> WindowReach { + ability_window_reach(&ability(card, index)) + } + + /// True when this ability's head is the unparsed gap node, i.e. its verdict + /// rides the fail-closed `_` arm rather than the effect's own semantics. + fn head_is_unparsed(card: &Card, index: usize) -> bool { + matches!( + ability(card, index).effect.as_ref(), + Effect::Unimplemented { .. } + ) + } + + const TERRAMORPHIC: Card = Card { + name: "Terramorphic Expanse", + oracle: "{T}, Sacrifice this land: Search your library for a basic land \ + card, put it onto the battlefield tapped, then shuffle.", + }; + /// Helping Hand, verbatim (Oracle text verified on Scryfall, + /// `api.scryfall.com/cards/named?exact=Helping+Hand`). The witness for the + /// origin gate: a tapped, You-controlled, unconditional, non-attacking + /// battlefield entry — confined on every axis the arm reads EXCEPT that the + /// arriving card comes from a graveyard, where it is a real object whose + /// rules content the fold has never classified. + const HELPING_HAND: Card = Card { + name: "Helping Hand", + oracle: "Return target creature card with mana value 3 or less from your \ + graveyard to the battlefield tapped.", + }; + const EVOLVING_WILDS: Card = Card { + name: "Evolving Wilds", + oracle: "{T}, Sacrifice this land: Search your library for a basic land \ + card, put it onto the battlefield tapped, then shuffle.", + }; + const RAMPANT_GROWTH: Card = Card { + name: "Rampant Growth", + oracle: "Search your library for a basic land card, put that card onto the battlefield \ + tapped, then shuffle.", + }; + const DEATHRITE_SHAMAN: Card = Card { + name: "Deathrite Shaman", + oracle: "{T}: Exile target land card from a graveyard. Add one mana of \ + any color.\n{B}, {T}: Exile target instant or sorcery card \ + from a graveyard. Each opponent loses 2 life.\n{G}, {T}: \ + Exile target creature card from a graveyard. You gain 2 life.", + }; + const SOUL_GUIDE_LANTERN: Card = Card { + name: "Soul-Guide Lantern", + oracle: "When this artifact enters, exile target card from a \ + graveyard.\n{T}, Sacrifice this artifact: Exile each \ + opponent's graveyard.\n{1}, {T}, Sacrifice this artifact: \ + Draw a card.", + }; + const RELIC_OF_PROGENITUS: Card = Card { + name: "Relic of Progenitus", + oracle: "{T}: Target player exiles a card from their \ + graveyard.\n{1}, Exile this artifact: Exile all graveyards. \ + Draw a card.", + }; + const SCAVENGING_OOZE: Card = Card { + name: "Scavenging Ooze", + oracle: "{G}: Exile target card from a graveyard. If it was a creature \ + card, put a +1/+1 counter on this creature and you gain 1 life.", + }; + const BRIBERY: Card = Card { + name: "Bribery", + oracle: "Search target opponent's library for a creature card and put that card onto the \ + battlefield under your control. Then that player shuffles.", + }; + const PRAETORS_GRASP: Card = Card { + name: "Praetor's Grasp", + oracle: + "Search target opponent's library for a card and exile it face down. Then that player \ + shuffles. You may play that card for as long as it remains exiled.", + }; + const HAUNTING_ECHOES: Card = Card { + name: "Haunting Echoes", + oracle: "Exile all cards from target player's graveyard other than basic land cards. For \ + each card exiled this way, search that player's library for all cards with the same name \ + as that card and exile them. Then that player shuffles.", + }; + const THOUGHTSEIZE: Card = Card { + name: "Thoughtseize", + oracle: "Target player reveals their hand. You choose a nonland card from \ + it. That player discards that card. You lose 2 life.", + }; + const DURESS: Card = Card { + name: "Duress", + oracle: "Target opponent reveals their hand. You choose a noncreature, nonland \ + card from it. That player discards that card.", + }; + const SURVEYORS_SCOPE: Card = Card { + name: "Surveyor's Scope", + oracle: + "{T}, Exile this artifact: Search your library for up to X basic land cards, where X \ + is the number of players who control at least two more lands than you. Put those \ + cards onto the battlefield, then shuffle.", + }; + const ASSASSINS_TROPHY: Card = Card { + name: "Assassin's Trophy", + oracle: "Destroy target permanent an opponent controls. Its controller \ + may search their library for a basic land card, put it onto \ + the battlefield, then shuffle.", + }; + const LIGHTNING_BOLT: Card = Card { + name: "Lightning Bolt", + oracle: "Lightning Bolt deals 3 damage to any target.", + }; + const WRATH_OF_GOD: Card = Card { + name: "Wrath of God", + oracle: "Destroy all creatures. They can't be regenerated.", + }; + const NATURALIZE: Card = Card { + name: "Naturalize", + oracle: "Destroy target artifact or enchantment.", + }; + const DIVINATION: Card = Card { + name: "Divination", + oracle: "Draw two cards.", + }; + const PATH_TO_EXILE: Card = Card { + name: "Path to Exile", + oracle: "Exile target creature. Its controller may search their library for a basic land \ + card, put that card onto the battlefield tapped, then shuffle.", + }; + /// Nature's Lore, verbatim (MTGJSON `.data["Nature's Lore"][0].text`). The + /// MINIMAL PAIR against `RAMPANT_GROWTH` above: same sentence shape, same + /// absent cost, same anaphoric `Library -> Battlefield / Any` node — and it + /// differs by the ONE printed word (`tapped`) the gate reads. + const NATURES_LORE: Card = Card { + name: "Nature's Lore", + oracle: "Search your library for a Forest card, put that card onto the battlefield, \ + then shuffle.", + }; + /// Crop Rotation, verbatim (MTGJSON, both lines). The reviewer's named + /// residual. MEASURED on the live parser: the additional-cost line is NOT + /// carried (`cost: None`, one ability), so its verdict rides the zone-change + /// node alone — which is exactly why it was allowlisted before this gate. + const CROP_ROTATION: Card = Card { + name: "Crop Rotation", + oracle: "As an additional cost to cast this spell, sacrifice a land.\n\ + Search your library for a land card, put that card onto the battlefield, \ + then shuffle.", + }; + /// Cultivate, verbatim (MTGJSON). The SECOND door onto the battlefield: its + /// search carries a `SearchDestinationSplit` and moves the found cards + /// ITSELF, with no `ChangeZone` sub-ability for the tap gate to read. + /// MEASURED over the corpus: 12 abilities carry a split at all, and every + /// battlefield-primary one prints "tapped" — so this fixture is the class, + /// not an example of it. + const CULTIVATE: Card = Card { + name: "Cultivate", + oracle: "Search your library for up to two basic land cards, reveal those cards, put one \ + onto the battlefield tapped and the other into your hand, then shuffle.", + }; + const SPREADING_SEAS: Card = Card { + name: "Spreading Seas", + oracle: "Enchant land\nWhen this Aura enters, draw a card.\nEnchanted land is an Island.", + }; + const DARK_RITUAL: Card = Card { + name: "Dark Ritual", + oracle: "Add {B}{B}{B}.", + }; + const LOTUS_PETAL: Card = Card { + name: "Lotus Petal", + oracle: "{T}, Sacrifice this artifact: Add one mana of any color.", + }; + + /// Verbatim from the pinned MTGJSON `AtomicCards.json` (`{3}{B}`, Instant), + /// not from memory. The two lines matter in opposite directions: the SECOND + /// is all this module can read, and the FIRST is the entire card. + const STUNNING_REVERSAL: Card = Card { + name: "Stunning Reversal", + oracle: "The next time you would lose the game this turn, instead draw seven cards \ + and your life total becomes 1.\nExile Stunning Reversal.", + }; + + /// V8 — the classifier is correct across the whole class, in BOTH + /// directions. Acceptance (a) must be confined; every acceptance-(b) + /// interaction, the graveyard-hate class the origin-keyed predecessor rule + /// wrongly confined, the foreign-library and foreign-hand analogues, and + /// the hostile pair must all be interference. + /// + /// Revert-probe: deleting any allowlisted arm flips its acceptance-(a) rows + /// to `MayInterfere`. That clause is TIGHTER than it used to be — there are + /// now three allowlisted arms and all three are exercised by the three + /// `confined` rows, whereas the `Effect::Mana` arm this table shipped with + /// had no acceptance-(a) row witnessing it at all. + /// + /// This table no longer witnesses the TRIVIALIZE mutant, and the sentence + /// that said it did has been removed rather than weakened. It claimed that + /// trivializing `filter_is_actor_owned` to `true` flips the + /// `Deathrite Shaman[0]` row to `OwnResourcesOnly`; MEASURED, that stopped + /// being true when `Effect::Mana` left the allowlist. `Deathrite Shaman[0]` + /// carries its "Add one mana of any color" clause as a `sub_ability`, which + /// now folds `MayInterfere` on its own, so the absorbing fold holds the row + /// no matter what the owner axis says. The owner axis keeps its full + /// discriminating power; only this table's claim to be its witness went + /// stale. Its witnesses are the two tests that assert on + /// `filter_is_actor_owned` DIRECTLY — + /// `deathrite_and_terramorphic_are_separated_only_by_the_owner_axis` and + /// `composite_filters_prove_ownership_by_set_logic`, both in this module. Do + /// NOT weaken any assertion here to restore the old sentence. + #[test] + fn window_reach_matches_the_measured_class_table() { + let confined: &[(&Card, usize)] = &[ + (&TERRAMORPHIC, 0), + (&EVOLVING_WILDS, 0), + (&RAMPANT_GROWTH, 0), + // Surveyor's Scope is deliberately NOT here — it is the fetch + // class's fail-closed member and gets its own row below, because + // its verdict rides its `Unimplemented` head and its + // non-allowlisted `Exile` cost rather than the classifier's + // hostile-detection logic. + ]; + let interfering: &[(&Card, usize)] = &[ + // acceptance (b) — real interaction must keep Shortening + (&LIGHTNING_BOLT, 0), + (&WRATH_OF_GOD, 0), + (&NATURALIZE, 0), + (&DIVINATION, 0), + (&PATH_TO_EXILE, 0), + // the B1 defect class: a graveyard is a per-player zone (CR 400.1) + // and none of these filters proves whose + (&DEATHRITE_SHAMAN, 0), + (&DEATHRITE_SHAMAN, 1), + (&SOUL_GUIDE_LANTERN, 0), + (&RELIC_OF_PROGENITUS, 1), + (&SCAVENGING_OOZE, 0), + // Library analogue — caught by `SearchLibrary.target_player` + (&BRIBERY, 0), + (&PRAETORS_GRASP, 0), + // Library analogue caught by the ABSORBING FOLD instead: its + // search node carries `target_player: None` and is confined on its + // own, so neither row above covers this path. The premise that + // makes it a fold witness is pinned separately by + // `a_foreign_library_search_is_confined_alone_then_absorbed`. + (&HAUNTING_ECHOES, 0), + // Hand analogue — these do not ride `ChangeZone` at all; they fall + // to the fail-closed default + (&THOUGHTSEIZE, 0), + (&DURESS, 0), + // hostile — a parsed removal spell that names an opponent's + // permanent explicitly + (&ASSASSINS_TROPHY, 0), + ]; + + for (card, index) in confined { + let name = card.name; + assert!( + !head_is_unparsed(card, *index), + "VACUITY GUARD: {name}[{index}] must PARSE — a confined verdict on an \ + Effect::Unimplemented head would be impossible, so this guard only ever \ + fires on a broken fixture" + ); + assert_eq!( + reach(card, *index), + WindowReach::OwnResourcesOnly, + "{name}[{index}] is a self-contained fetch: it must NOT buy a priority window" + ); + } + for (card, index) in interfering { + let name = card.name; + // The load-bearing anti-vacuity guard. Every row here must reach + // MayInterfere through the classifier's own logic on a PARSED + // effect. Without this, a row whose Oracle text merely failed to + // parse would sit in the table looking like hostile-detection + // coverage while actually exercising only the fail-closed arm — + // and the whole parsed-hostile branch could then be deleted with + // the suite still green. (MEASURED: `Lightning Bolt` did exactly + // this in an earlier revision, because it was parsed under a + // placeholder card name and its self-reference never resolved.) + assert!( + !head_is_unparsed(card, *index), + "VACUITY GUARD: {name}[{index}] rides the Effect::Unimplemented fail-closed \ + arm, so it is NOT a witness for parsed-hostile detection" + ); + assert_eq!( + reach(card, *index), + WindowReach::MayInterfere, + "{name}[{index}] can reach past its controller's own resources" + ); + } + } + + /// The `SearchLibrary` arm's safety warrant is the ABSORBING fold, not the + /// arm itself — and the two library rows in the table above do NOT witness + /// it: `Bribery[0]` and `Praetor's Grasp[0]` each carry + /// `target_player: Typed{controller: Opponent}`, so the arm catches them + /// directly and the fold is never asked to do anything. This test pins the + /// path they miss, on one of the five abilities the arm's own comment names + /// as foreign-library-with-`target_player: None`. + /// + /// MEASURED at this base: `Haunting Echoes[0]` searches "that player's + /// library" — FOREIGN — yet its `sub_ability` heads + /// `SearchLibrary { target_player: None }`, so that node ALONE classifies + /// `OwnResourcesOnly`. What makes the ability interference is its sibling + /// head, `ChangeZoneAll` (graveyard exile, not an allowlisted effect). + /// + /// The two assertions name the fail-open node and its absorber; the + /// composed verdict is the `HAUNTING_ECHOES` row in the interfering table + /// above, so no assertion here duplicates one there. + /// + /// Read the split correctly: MEASURED, that table row's verdict is carried + /// ENTIRELY by the `ChangeZoneAll` head — the ability has no cost, no + /// `player_scope` and no conservative-when-present field, so dropping the + /// fold's `sub_ability` leg leaves it `MayInterfere` unchanged. The reason is + /// specific to THIS fixture and does NOT generalize: in + /// `ability_window_reach` the head is the only term that is not optional, so + /// when the absorber IS the head, there is no leg the fold could lose that + /// would carry it away. Do NOT read that as "an absorbed verdict can never + /// flip" — when the absorbers sit on OPTIONAL legs, the verdict flips as soon + /// as those legs go. MEASURED at this base by fresh `parse_oracle_text` on + /// `Jace, Architect of Thought[2]`, another of the five abilities the + /// `SearchLibrary` arm's own comment names: its head is + /// `SearchLibrary { target_player: None }` — `OwnResourcesOnly` — and all + /// THREE of its absorbers are optional legs: `cost: Loyalty(-8)` (the + /// `cost_window_reach` wildcard), a `sub_ability` heading + /// `ChangeZone { Library -> Exile, target: Any }`, and `player_scope: All`. + /// Dropping the `sub_ability` and `cost` legs together still measures + /// `MayInterfere`, because `player_scope` alone still absorbs; dropping all + /// three measures `OwnResourcesOnly`. That card is NOT a fixture in this + /// suite, so nothing here pins it — it is cited to bound the claim above, not + /// as coverage. What remains lockable on Haunting Echoes is therefore the + /// premise, and the first assertion is what locks it. MEASURED: closing the + /// `SearchLibrary` arm (`WindowReach::of(target_player.is_none())` to a bare + /// `MayInterfere`) flips that assertion. It cannot flip `Bribery[0]` or + /// `Praetor's Grasp[0]` — DERIVED, not measured: closing the arm can only + /// move a verdict toward `MayInterfere`, and both are already there. + #[test] + fn a_foreign_library_search_is_confined_alone_then_absorbed() { + let echoes = ability(&HAUNTING_ECHOES, 0); + let search = echoes + .sub_ability + .as_ref() + .expect("PREMISE: Haunting Echoes[0] carries the library search as its sub_ability"); + assert_eq!( + effect_window_reach(&search.effect), + WindowReach::OwnResourcesOnly, + "PREMISE: the foreign-library search must still look CONFINED on its own — that is \ + the fail-open shape the fold exists to absorb. If this flips, the parser now carries \ + the foreign `target_player` and this row has silently become a second Bribery" + ); + assert_eq!( + effect_window_reach(&echoes.effect), + WindowReach::MayInterfere, + "the absorber: the sibling graveyard-exile head is what the fold ORs in" + ); + } + + /// The fetch class's ONE fail-open candidate, pinned with its mechanism + /// visible instead of hidden among the parsed rows. + /// + /// Surveyor's Scope is shaped like acceptance (a) — it searches the actor's + /// own library for the actor's own basics and shuffles the actor's own + /// library — but at this base the parser does not carry its whole sentence + /// ("where X is the number of players who control at least two more lands + /// than you"), so `abilities[0]` heads an `Effect::Unimplemented`. + /// + /// The verdict is JOINTLY determined, and the two assertions below are not + /// equally load-bearing. MEASURED at this base: the head reaches + /// `effect_window_reach`'s fail-closed `_` arm, AND the cost is + /// `Composite[Tap, Exile{filter: SelfRef}]` — `AbilityCost::Exile` is not + /// allowlisted, so `cost_window_reach` returns `MayInterfere` on its own. + /// Therefore: + /// + /// * the head assertion is the one that carries weight. If it ever flips + /// (the parser learns the sentence), this row fails LOUDLY rather than + /// silently changing which arm it exercises — the failure is the signal + /// to re-verify the verdict on the parsed AST and, if it is then + /// confined, move it into the confined table above. Do NOT delete it as + /// "redundant with the verdict": that leaves a vacuous row; + /// * the verdict assertion is DOMINATED by the cost leg — it would still + /// hold if the head parsed to a fully confined fetch, so it is NOT + /// evidence that the `_` effect arm fired. It is kept because it is the + /// class-level statement "the fetch class has no fail-open member". + /// + /// Kept OUT of the two tables deliberately: an `Unimplemented` row placed + /// among the interfering rows would look like hostile-detection coverage + /// while exercising only the fail-closed arm, which is the exact vacuity + /// the tables' guards exist to forbid. + #[test] + fn surveyors_scope_is_the_fetch_classs_fail_closed_member() { + assert!( + head_is_unparsed(&SURVEYORS_SCOPE, 0), + "PREMISE: this row exists because the head is UNPARSED at this base. If the parser \ + has learned the sentence, re-measure the parsed AST and reclassify the row \ + deliberately — do not delete this assertion to make the suite green" + ); + assert_eq!( + reach(&SURVEYORS_SCOPE, 0), + WindowReach::MayInterfere, + "the fetch class has no fail-open member: an ability the parser cannot carry must \ + never be proven confined" + ); + } + + /// V1c's premise, at AST granularity: the ONLY thing separating + /// `Deathrite Shaman[0]` from `Terramorphic Expanse[0]` is + /// `filter_is_actor_owned`. Trivializing it merges them — which is exactly + /// the defect the integration row pins on a real board. + #[test] + fn deathrite_and_terramorphic_are_separated_only_by_the_owner_axis() { + let drs = ability(&DEATHRITE_SHAMAN, 0); + let Effect::ChangeZone { target, origin, .. } = drs.effect.as_ref() else { + panic!( + "Deathrite Shaman[0] must head a ChangeZone, got {:?}", + drs.effect + ); + }; + assert_eq!( + *origin, + Some(Zone::Graveyard), + "the graveyard origin is what the refuted origin-keyed rule keyed on" + ); + assert!( + !filter_is_actor_owned(target), + "a bare graveyard-card filter names no owner (CR 400.1), so ownership is UNPROVEN — \ + this is the whole of the B1 fix" + ); + + let terramorphic = ability(&TERRAMORPHIC, 0); + let Some(AbilityCost::Composite { costs }) = terramorphic.cost.as_ref() else { + panic!("Terramorphic Expanse[0] must carry a composite cost"); + }; + assert!( + costs.iter().any( + |c| matches!(c, AbilityCost::Sacrifice(s) if filter_is_actor_owned(&s.target)) + ), + "Terramorphic sacrifices ITSELF — proven actor-owned, which is why the fix is \ + surgical rather than a blanket flip to MayInterfere" + ); + } + + /// V8c — the row that discharges B2. An unrecognized nesting container is + /// interference regardless of what it nests: every branch below is + /// individually confined, and the container still folds to `MayInterfere`. + /// + /// Revert-probe: changing `effect_window_reach`'s `_` arm to + /// `OwnResourcesOnly` flips this assertion. + #[test] + fn an_unrecognized_container_with_confined_branches_is_still_interference() { + let confined_branch = ability(&RAMPANT_GROWTH, 0); + assert_eq!( + ability_window_reach(&confined_branch), + WindowReach::OwnResourcesOnly, + "reach-guard: the branch really is confined, so the container's verdict below is \ + attributable to the container and not to its contents" + ); + + let container = Effect::ChooseOneOf { + chooser: PlayerFilter::Controller, + branches: vec![confined_branch.clone(), confined_branch], + }; + assert_eq!( + effect_window_reach(&container), + WindowReach::MayInterfere, + "an unallowlisted container is interference regardless of what it nests" + ); + + assert_eq!( + effect_window_reach(&Effect::unimplemented("test", "unparsed fragment")), + WindowReach::MayInterfere, + "an unparsed effect is the Surveyor's Scope path — never confined" + ); + } + + /// An object is not the same thing as its ability list, and proving the + /// ability list confined proves nothing about the object. + /// + /// `game::printed_cards` splits one card face across four collections. This + /// module can classify exactly one of them. Before the gate in + /// `object_window_reach`, a card whose ability list was the confined fraction + /// and whose real content sat in another collection read `OwnResourcesOnly`. + /// + /// The witness is real, not constructed: `Stunning Reversal` parses to one + /// ability — `Exile ~`, i.e. `ChangeZone { destination: Exile, target: SelfRef + /// }`, actor-owned and off the battlefield, so confined on every conjunct this + /// module reads — plus one `GameLoss` replacement carrying the whole card. A + /// seat holding it would have Accepted the shortcut the card exists to + /// survive, which is the direction §2 calls the one that loses games. + /// + /// Discrimination is measured, not asserted, and by construction rather than + /// by a second fixture: the two objects below differ in exactly one field. + /// The CONTROL clears `replacement_definitions` and nothing else, and it must + /// return `OwnResourcesOnly` — so the guarded verdict cannot be a constant, + /// and it is attributable to that field alone. + /// + /// Revert-probe: delete the `carries_unreadable_rules_content` call from + /// `object_window_reach` and this row's first assertion reds while the control + /// stays green. Cutting a single disjunct out of that function instead reds only + /// the row for that disjunct, which is how each one is shown to carry its own + /// weight rather than riding the first. + #[test] + fn an_object_whose_rules_content_this_module_cannot_read_is_never_confined() { + let parsed = parse_oracle_text( + STUNNING_REVERSAL.oracle, + STUNNING_REVERSAL.name, + &[], + &[], + &[], + ); + assert_eq!( + parsed.abilities.len(), + 1, + "PREMISE: the card projects exactly one ability; got {:?}", + parsed.abilities + ); + assert_eq!( + parsed.replacements.len(), + 1, + "PREMISE: and exactly one replacement — the half this module cannot read; got {:?}", + parsed.replacements + ); + assert_eq!( + ability_window_reach(&parsed.abilities[0]), + WindowReach::OwnResourcesOnly, + "PREMISE: the ability half really is confined on its own, so the verdict below is \ + produced by the GATE and not by the ability list" + ); + + // `game::zones::create_object` is the shared primitive for this: it allocates the id, + // builds the object AND registers it in its zone. Hand-rolling the insert here would + // duplicate it and, worse, would leave the object absent from the hand's own id list. + let mut state = GameState::default(); + let id = create_object( + &mut state, + crate::types::identifiers::CardId(1), + crate::types::player::PlayerId(0), + STUNNING_REVERSAL.name.to_string(), + Zone::Hand, + ); + let object = state.objects.get_mut(&id).expect("just created"); + object.abilities = std::sync::Arc::new(parsed.abilities.clone()); + object.replacement_definitions = parsed.replacements.clone().into(); + + assert_eq!( + object_window_reach(&state, id), + WindowReach::MayInterfere, + "a GameLoss replacement this module cannot classify is not proof of confinement" + ); + assert_eq!( + indexed_ability_window_reach(&state, id, 0), + WindowReach::MayInterfere, + "the activation path takes the same gate: a trigger or replacement on the SAME object \ + can fire off the activation (CR 603.2)" + ); + + // CONTROL — one field cleared IN PLACE, nothing else touched. + state + .objects + .get_mut(&id) + .expect("still present") + .replacement_definitions = Vec::new().into(); + assert_eq!( + object_window_reach(&state, id), + WindowReach::OwnResourcesOnly, + "REACH-GUARD: with the unreadable collection gone the SAME object is confined, so the \ + two verdicts above are attributable to that one field and are not a constant" + ); + + // The TRIGGER disjunct, exercised through the real materialization path rather than + // asserted from reading it. The three collections are not wired alike: `printed_cards` + // assigns `replacement_definitions` and `static_definitions` directly, but routes triggers + // through `base_trigger_definitions` + `materialize_base_trigger_definitions()`. A gate that + // reads the materialized field while the pipeline only ever fills the printed one would be + // silently blind to every trigger, so the wiring is the thing under test here, not the + // `is_empty()` call. + let triggers = parse_oracle_text( + SOUL_GUIDE_LANTERN.oracle, + SOUL_GUIDE_LANTERN.name, + &[], + &[], + &[], + ) + .triggers; + assert_eq!( + triggers.len(), + 1, + "PREMISE: Soul-Guide Lantern's ETB is a real parsed trigger; got {triggers:?}" + ); + let object = state.objects.get_mut(&id).expect("still present"); + object.base_trigger_definitions = std::sync::Arc::new(triggers); + object.materialize_base_trigger_definitions(); + assert!( + !object.trigger_definitions.is_empty(), + "PREMISE: materializing the printed triggers must populate the field the gate reads — \ + if this ever fails the gate is blind to triggers no matter what it returns" + ); + assert_eq!( + object_window_reach(&state, id), + WindowReach::MayInterfere, + "a trigger this module cannot classify is not proof of confinement either" + ); + + // The STATIC disjunct — the third of three, and the one a review round + // correctly noted had neither a test nor a probe. 7 of the 19 printed cards + // this gate flips at today's pool are static-only carriers, so an untested + // disjunct here would leave better than a THIRD of the gate's own measured + // effect unexercised. + let object = state.objects.get_mut(&id).expect("still present"); + object.base_trigger_definitions = std::sync::Arc::new(Vec::new()); + object.materialize_base_trigger_definitions(); + assert!( + object.trigger_definitions.is_empty(), + "PREMISE: triggers cleared, so the next verdict cannot be the trigger clause again" + ); + assert_eq!( + object_window_reach(&state, id), + WindowReach::OwnResourcesOnly, + "CONTROL: with every unreadable collection empty the SAME object is confined again" + ); + let parsed_statics = + parse_oracle_text(SPREADING_SEAS.oracle, SPREADING_SEAS.name, &[], &[], &[]).statics; + assert!( + !parsed_statics.is_empty(), + "PREMISE: Spreading Seas carries a real parsed static ability; got {parsed_statics:?}" + ); + state + .objects + .get_mut(&id) + .expect("still present") + .static_definitions = parsed_statics.into(); + assert_eq!( + object_window_reach(&state, id), + WindowReach::MayInterfere, + "nor is a static ability this module cannot classify proof of confinement" + ); + + // The KEYWORD disjunct. This is the one a review round caught: the gate used to + // read three collections while `printed_cards` writes far more, and printed + // Cascade lives in `obj.keywords` and never reaches `trigger_definitions` — so a + // Cascade spell whose printed abilities all read confined was provably + // `OwnResourcesOnly` while resolving it casts a free spell of arbitrary reach. + let object = state.objects.get_mut(&id).expect("still present"); + object.static_definitions = Vec::new().into(); + assert_eq!( + object_window_reach(&state, id), + WindowReach::OwnResourcesOnly, + "CONTROL: statics cleared, so the keyword verdict below is the keyword's alone" + ); + state + .objects + .get_mut(&id) + .expect("still present") + .keywords + .push(Keyword::Cascade); + assert_eq!( + object_window_reach(&state, id), + WindowReach::MayInterfere, + "a keyword is rules text this fold never reads; Cascade resolves a free spell of \ + arbitrary reach inside the window the seat would have declined to keep" + ); + assert_eq!( + indexed_ability_window_reach(&state, id, 0), + WindowReach::MayInterfere, + "and the activation path takes the same widened gate, not the old three-field one" + ); + + // A CASTING-TIME MODIFIER, to show the widening is not keyword-specific. + let object = state.objects.get_mut(&id).expect("still present"); + object.keywords.clear(); + assert_eq!( + object_window_reach(&state, id), + WindowReach::OwnResourcesOnly, + "CONTROL: keyword cleared and the SAME object is confined again, so neither verdict \ + above is a constant" + ); + state + .objects + .get_mut(&id) + .expect("still present") + .spellbook + .push("Lightning Bolt".to_string()); + assert_eq!( + object_window_reach(&state, id), + WindowReach::MayInterfere, + "a spellbook names cards this object can reach for, and it is not an \ + AbilityDefinition either — the gate is over unreadable CONTENT, not over one field" + ); + + // The four fields the STALENESS GUARD surfaced. They are inert at today's pool + // (0 carriers among the cards that survive the gate), which is exactly why they + // need a witness here: an inert disjunct with no test is indistinguishable from a + // disjunct that does nothing, and the next person to tidy this function would have + // no way to tell. Each is set on the SAME object with every other unreadable field + // cleared first, so each verdict is attributable to that one field. + // Named rather than written inline: `clippy::type_complexity` rejects the tuple-of-fn-ptr + // array form, and the alias is the lint's own suggested remedy. + type FieldSetter = fn(&mut GameObject); + let setters: [(&str, FieldSetter); 4] = [ + ("case_state", |o| { + o.case_state = Some(crate::game::game_object::CaseState { + is_solved: false, + solve_condition: crate::types::ability::SolveCondition::Text { + description: "probe".to_string(), + }, + }) + }), + ("class_level", |o| o.class_level = Some(1)), + ("intensity", |o| o.intensity = 1), + ("attraction_lights", |o| o.attraction_lights = vec![1]), + ]; + for (field, set) in setters { + let object = state.objects.get_mut(&id).expect("still present"); + object.spellbook.clear(); + object.case_state = None; + object.class_level = None; + object.intensity = 0; + object.attraction_lights.clear(); + assert_eq!( + object_window_reach(&state, id), + WindowReach::OwnResourcesOnly, + "CONTROL before {field}: with every unreadable field cleared the SAME object \ + is confined again, so the verdict below is attributable to {field} alone" + ); + set(state.objects.get_mut(&id).expect("still present")); + assert_eq!( + object_window_reach(&state, id), + WindowReach::MayInterfere, + "{field} is rules content this fold cannot read, so it is not proof of confinement" + ); + } + } + + /// STALENESS GUARD. Every printed rules field is either folded or gated. + /// + /// The defect this exists to prevent has now happened twice on this one gate: it + /// shipped reading three collections while `printed_cards` wrote eleven fields, and a + /// review round found `keywords` (printed Cascade) that way. Writing THIS test then + /// found four more — `case_state`, `class_level`, `intensity`, `attraction_lights` — + /// which no amount of re-reading the gate by hand had surfaced. An enumerated list is + /// only ever correct on the day it is written; a `..`-free destructure is correct + /// until the compiler says otherwise. + /// + /// **Why `CardFace` and not `GameObject`.** `GameObject` has 149 fields, most of them + /// runtime state (zone, damage, counters, attachments) with no bearing on what a card + /// can do. Destructuring it here would be a churn magnet that every unrelated field + /// addition breaks, and it would be blanket-`..`'d back within a round. `CardFace` has + /// 33 and is the actual source `printed_cards` reads to populate object rules content, + /// so it guards the defect class that occurred rather than the largest surface + /// available. + /// + /// **Honest scope limit:** this guards fields that reach an object THROUGH + /// `printed_cards`. A `GameObject` field written by some other path is not covered — + /// `game::stickers` is the live example, and it writes only the three definition + /// collections, which are gated. + #[test] + fn every_printed_rules_field_is_either_folded_or_gated() { + // `..`-free ON PURPOSE. A new `CardFace` field is a COMPILE ERROR here until + // someone sorts it into one of the three buckets. The sort IS the assertion: + // there is nothing to run, and that is the point — this fires at build time, + // when it can still be cheap, rather than at review time. + let CardFace { + // ---- FOLDED: this module classifies these itself. + abilities: _, + + // ---- GATED: unreadable rules content. `carries_unreadable_rules_content` + // returns true on the corresponding `GameObject` field. + keywords: _, + triggers: _, + static_abilities: _, + replacements: _, + cleave_variant: _, + modal: _, + additional_cost: _, + casting_restrictions: _, + casting_options: _, + strive_cost: _, + solve_condition: _, // lands as `obj.case_state` + attraction_lights: _, + // GATED as `obj.parse_warnings`. It was in the NOT-RULES-BEARING bucket + // below, reasoned "parser diagnostics, never consulted at runtime". That + // reason was TRUE about the field and WRONG about the conclusion: a + // diagnostic is not rules text, it is the parser's report that some printed + // rules text is MISSING from `abilities`, which is the one thing that makes + // an otherwise-confined fold unsound (module doc §2's named residual). + parse_warnings: _, + // `metadata` is MIXED, so it is DESTRUCTURED rather than bound whole. Binding + // it was a hole in the guard that the guard's own comment declared and did not + // close: `spellbook` is exactly a rules-bearing field that arrived inside this + // struct, so "mixed" is the reason to open it, not the reason to wave it past. + metadata: + CardMetadata { + // GATED as `obj.spellbook`. + spellbook: _, + // Parser-provenance counters: how many abilities came from Forge + // scripts rather than the Oracle parser. The abilities themselves are + // in the collections above; these are counts of them. + forge_abilities: _, + forge_triggers: _, + forge_statics: _, + forge_replacements: _, + // Names tokens this card can make; MAKING one runs through + // `Effect::Token` in `abilities`, which this fold already reads. + related_token_ids: _, + // Image/catalog identifiers. + source_printing_ids: _, + // CR 202.3d + CR 709.4b: a split card's combined off-stack mana value, + // read by deck-construction checks. A cost, not what resolving does. + off_stack_mana_value_override: _, + }, + + // ---- NOT RULES-BEARING. One reason each, because an unjustified entry here + // is exactly where the next lazy re-bucket lands. + name: _, // identity, not behaviour + mana_cost: _, // cost to cast, not what resolving does + // NOT inert, and the earlier reason here ("types/subtypes gate other rules, + // carry none alone") was measurably WRONG. `printed_cards` derives four + // object-level rules fields from `subtypes` ALONE, with no CardFace field + // behind them: `Class` => `class_level` (:206), `Case` => `case_state` (:238), + // `Room` => `room_unlocks` (:246), `Attraction` => `attraction_lights` (:250). + // The face's own field is therefore safe to skip only BECAUSE all four + // derived fields are gated — and `room_unlocks` was not, until the review + // round that read this line. A wrong reason in this bucket is worse than no + // reason: it certifies that no gate is needed. + card_type: _, + power: _, // combat statistic + toughness: _, // combat statistic + loyalty: _, // resource counter, abilities that spend it are in `abilities` + defense: _, // battle counter, same argument as loyalty + oracle_text: _, // the SOURCE the parser reads; the parse is the rules content + non_ability_text: _, // by definition not an ability + flavor_name: _, // cosmetic + color_override: _, // colour is a characteristic, not an action + color_identity: _, // deck construction (CR 903.4), not in-game behaviour + scryfall_oracle_id: _, // external identifier + brawl_commander: _, // format eligibility + is_commander: _, // format eligibility + is_oathbreaker: _, // format eligibility + deck_copy_limit: _, // deck construction + rarities: _, // printing metadata + } = CardFace::default(); + } + + /// Every GATED bucket entry above must actually REACH the gate, through the pipeline + /// that really populates it. + /// + /// This exists because a review round measured that seven of the gate's disjuncts had + /// no test and no revert-probe — `modal`, `additional_cost`, `strive_cost`, + /// `cleave_variant`, `casting_restrictions`, `casting_options`, `back_face` — each + /// occurring exactly once in the whole file, in the gate itself. Deleting any of them + /// left the entire suite green. The sting: `modal` and `additional_cost` are 2 of the + /// 10 cards the widening actually flips, so the disjuncts with no coverage were the + /// LIVE ones while the four that had witnesses were the inert ones. The guard above is + /// a compile-time claim that each field is classified; this is the runtime claim that + /// the classification is TRUE. + /// + /// It also turns the guard's `CardFace` → `GameObject` mapping from a comment into an + /// assertion. Each case mutates a **`CardFace`** and runs the real + /// `apply_card_face_to_object`, so a rename or a dropped copy in `printed_cards` reds + /// here instead of silently un-gating the field — which is the failure the `spellbook` + /// and `keywords` holes both took to get in. + #[test] + fn every_gated_card_face_field_reaches_the_gate_through_printed_cards() { + use crate::game::printed_cards::apply_card_face_to_object; + + // `clippy::type_complexity` rejects the inline tuple-of-fn-ptr array; the alias is + // the lint's own suggested remedy. + type FaceSetter = fn(&mut CardFace); + let cases: [(&str, FaceSetter); 13] = [ + // --- carried on the face itself. + ("keywords", |f| f.keywords = vec![Keyword::Cascade]), + ("cleave_variant", |f| { + f.cleave_variant = Some(crate::types::card::CleaveVariant::default()) + }), + ("modal", |f| { + f.modal = Some(crate::types::ability::ModalChoice::default()) + }), + ("additional_cost", |f| { + f.additional_cost = Some(crate::types::ability::AdditionalCost::Kicker { + costs: vec![], + repeatability: crate::types::ability::AdditionalCostRepeatability::Once, + }) + }), + ("casting_restrictions", |f| { + f.casting_restrictions = vec![crate::types::ability::CastingRestriction::AsSorcery] + }), + ("casting_options", |f| { + f.casting_options = vec![crate::types::ability::SpellCastingOption::free_cast()] + }), + ("strive_cost", |f| { + f.strive_cost = Some(crate::types::mana::ManaCost::default()) + }), + ("metadata.spellbook", |f| { + f.metadata.spellbook = vec!["Lightning Bolt".to_string()] + }), + // The parser's own report that some printed clause is NOT in `abilities`. + // `IgnoredRemainder` is the cheapest variant to build and the gate is a + // presence check, so the variant is not load-bearing — that the field + // SURVIVES `apply_card_face_to_object` is. + ("parse_warnings", |f| { + f.parse_warnings = vec![ + crate::parser::oracle_ir::diagnostic::OracleDiagnostic::IgnoredRemainder { + text: "and you gain 2 life".to_string(), + parser: "probe".to_string(), + line_index: 0, + }, + ] + }), + // --- DERIVED FROM `card_type.subtypes` ALONE, with no face field behind them. + // These four are why the guard's `card_type` bucket reason had to be rewritten: + // the face's own `card_type` is safe to skip only because all four landing + // fields are gated, and `room_unlocks` was not until this round. + ("card_type: Class → class_level", |f| { + f.card_type.subtypes = vec!["Class".to_string()] + }), + ("card_type: Case → case_state", |f| { + f.card_type.subtypes = vec!["Case".to_string()]; + f.solve_condition = Some(crate::types::ability::SolveCondition::Text { + description: "probe".to_string(), + }); + }), + ("card_type: Room → room_unlocks", |f| { + f.card_type.subtypes = vec!["Room".to_string()] + }), + ("card_type: Attraction → attraction_lights", |f| { + f.card_type.subtypes = vec!["Attraction".to_string()] + }), + ]; + + for (field, set) in cases { + // CONTROL and WITNESS are SEPARATE freshly-created objects rather than one + // object applied twice. `printed_cards` seeds `class_level` only when + // `base_characteristics_initialized` is still false (CR 716.2b), so re-applying + // to the same object would silently skip the very field the Class case tests — + // and the case would pass for the wrong reason on the three cases beside it. + let mut face = CardFace { + name: "Probe".to_string(), + ..CardFace::default() + }; + let mut state = GameState::default(); + + let control = create_object( + &mut state, + crate::types::identifiers::CardId(1), + crate::types::player::PlayerId(0), + face.name.clone(), + Zone::Hand, + ); + apply_card_face_to_object( + state.objects.get_mut(&control).expect("just created"), + &face, + ); + assert!( + !carries_unreadable_rules_content( + state.objects.get(&control).expect("just created") + ), + "CONTROL for {field}: a default face lands no unreadable content, so the \ + verdict below is produced by {field} and not by the face it rides on" + ); + + set(&mut face); + let witness = create_object( + &mut state, + crate::types::identifiers::CardId(2), + crate::types::player::PlayerId(0), + face.name.clone(), + Zone::Hand, + ); + apply_card_face_to_object( + state.objects.get_mut(&witness).expect("just created"), + &face, + ); + assert!( + carries_unreadable_rules_content( + state.objects.get(&witness).expect("just created") + ), + "CardFace {field} is rules content this fold cannot read, so `printed_cards` \ + must land it somewhere the gate looks — either the gate lost a disjunct or \ + the copy into `GameObject` was renamed out from under it" + ); + } + + // `back_face` is the one gated field with no `apply_card_face_to_object` route: + // `printed_cards::apply_card_face_to_back_face` fills a `BackFaceData` on the + // transform path instead. Asserted directly, and reusing `game::specialize`'s + // existing empty constructor rather than hand-rolling a 22-field literal that would + // go stale the moment `BackFaceData` gains a field. + let mut state = GameState::default(); + let id = create_object( + &mut state, + crate::types::identifiers::CardId(3), + crate::types::player::PlayerId(0), + "Probe".to_string(), + Zone::Hand, + ); + assert!( + !carries_unreadable_rules_content(state.objects.get(&id).expect("just created")), + "CONTROL for back_face: a bare object carries nothing the gate reads" + ); + state.objects.get_mut(&id).expect("just created").back_face = + Some(crate::game::specialize::empty_back_face()); + assert!( + carries_unreadable_rules_content(state.objects.get(&id).expect("just created")), + "a back face is an entire second face of rules content this fold never descends \ + into, so its presence is not proof of confinement" + ); + } + + /// The destination axis, on a REAL parsed node, one field mutated. + /// + /// `Stunning Reversal`'s ability is `ChangeZone { destination: Exile, target: + /// SelfRef }`, so `object_is_confined` holds by ownership alone and does not + /// depend on `destination` — which makes `destination` the only thing the + /// verdicts below can be measuring. (The anaphoric fetch node cannot serve + /// here: its `object_is_confined` disjunct itself requires + /// `destination == Battlefield`, so mutating the field would move two things + /// at once and the row would prove nothing.) + /// + /// `Hand` is gated for the same reason a `Battlefield`-untapped arrival is: a + /// card put into hand is a CASTABLE card. The spell that put it there + /// resolves, the active player receives priority (CR 117.3b) and priority then + /// passes in turn order (CR 117.3d), so the responding seat gets it back still + /// inside this window and can cast it. Graveyard, library and exile are not gated + /// because the actor cannot cast or activate from them without some further + /// permission — which would itself be an ability this fold already reads. + /// + /// Both directions are present, so neither verdict can be a constant. + #[test] + fn a_destination_is_confined_only_when_the_seat_cannot_act_on_what_lands_there() { + let parsed = parse_oracle_text( + STUNNING_REVERSAL.oracle, + STUNNING_REVERSAL.name, + &[], + &[], + &[], + ); + let node = parsed.abilities[0].effect.as_ref().clone(); + let Effect::ChangeZone { target, .. } = &node else { + panic!("PREMISE: the node must head a ChangeZone; got {node:?}"); + }; + assert!( + filter_is_actor_owned(target), + "PREMISE: the target is actor-owned, so `object_is_confined` holds independently of \ + `destination` and every verdict below is attributable to the destination alone" + ); + + // Every one of `Zone`'s seven variants (CR 400.1) appears here. The previous + // table listed five and the production match closed the gap with `_ => true`, + // so `Zone::Stack` was classified confined by a wildcard and no row noticed. + let table = [ + (Zone::Exile, WindowReach::OwnResourcesOnly), + (Zone::Graveyard, WindowReach::OwnResourcesOnly), + (Zone::Library, WindowReach::OwnResourcesOnly), + (Zone::Hand, WindowReach::MayInterfere), + // Already past casting (CR 405.1) and resolves inside the window + // (CR 608.1) — strictly stronger reach than the hand row above. + (Zone::Stack, WindowReach::MayInterfere), + // CR 903.8: a commander may be cast from here. + (Zone::Command, WindowReach::MayInterfere), + // `enter_tapped` is `Unspecified` on this node, so the battlefield + // arm is reach for the TAP reason, not the destination reason. + (Zone::Battlefield, WindowReach::MayInterfere), + ]; + + // COMPLETENESS GUARD, compile-time half: this match is exhaustive over `Zone`, + // so adding a variant breaks THIS test's build and forces a row decision here + // as well as in the production match. The runtime half below then catches a + // variant that compiles but was left out of `table`. Deliberately NOT a mirror + // of the production match — it asserts coverage only, never a verdict, so it + // cannot pass by agreeing with a wrong implementation. + for zone in [ + Zone::Library, + Zone::Hand, + Zone::Battlefield, + Zone::Graveyard, + Zone::Stack, + Zone::Exile, + Zone::Command, + ] { + match zone { + Zone::Library + | Zone::Hand + | Zone::Battlefield + | Zone::Graveyard + | Zone::Stack + | Zone::Exile + | Zone::Command => {} + } + assert!( + table.iter().any(|(z, _)| *z == zone), + "Zone::{zone:?} has no row in the destination table — every zone must be \ + classified explicitly, because the failure this test exists for is a \ + destination nobody wrote a row for" + ); + } + + for (zone, want) in table { + let mut mutated = node.clone(); + let Effect::ChangeZone { destination, .. } = &mut mutated else { + unreachable!("just matched above") + }; + *destination = zone; + assert_eq!( + effect_window_reach(&mutated), + want, + "destination {zone:?} must classify {want:?}" + ); + } + } + + /// V8's hostile edges on the action fold: nothing resolvable, nothing to + /// resolve, and an index past the end are all interference. + #[test] + fn unresolvable_action_subjects_are_interference() { + let state = GameState::default(); + let missing = ObjectId(9_999_999); + assert_eq!( + object_window_reach(&state, missing), + WindowReach::MayInterfere, + "an object that is not on the board cannot be proven confined" + ); + assert_eq!( + indexed_ability_window_reach(&state, missing, 0), + WindowReach::MayInterfere, + "neither can an ability index into an object that is not on the board" + ); + assert!( + any_action_may_interfere( + &state, + PlayerId(0), + &[GameAction::ActivateAbility { + source_id: missing, + ability_index: 7, + }] + ), + "an out-of-range ability index is interference, not a confined no-op" + ); + assert!( + !any_action_may_interfere(&state, PlayerId(0), &[GameAction::PassPriority]), + "reach-guard: the fold CAN return false, so the trues above are attributable" + ); + } + + /// The `Or`/`And` legs are set logic, not a coin flip, and an empty leg set + /// is never proven. + #[test] + fn composite_filters_prove_ownership_by_set_logic() { + let owned = TargetFilter::Controller; + let unowned = TargetFilter::Any; + + assert!( + !filter_is_actor_owned(&TargetFilter::Or { + filters: vec![owned.clone(), unowned.clone()], + }), + "an Or is proven only when EVERY leg is proven" + ); + assert!( + filter_is_actor_owned(&TargetFilter::Or { + filters: vec![owned.clone(), owned.clone()], + }), + "reach-guard: an all-proven Or IS proven, so the negative above is not vacuous" + ); + assert!( + filter_is_actor_owned(&TargetFilter::And { + filters: vec![owned, unowned], + }), + "an And narrows, so one proven leg suffices" + ); + assert!( + !filter_is_actor_owned(&TargetFilter::Or { filters: vec![] }), + "a degenerate empty Or must not be proven by a vacuous all()" + ); + } + + /// Mana is FUNGIBLE REACH (CR 106.1 / CR 106.4 / CR 601.2g), so neither a + /// cast ritual nor an actor-owned sacrifice-for-mana is confined. + /// + /// The Lotus Petal half is the load-bearing one, and its ATTRIBUTION is the + /// second assertion: the parser emits "Sacrifice this artifact" as a + /// `TargetFilter::SelfRef`, which `filter_is_actor_owned` returns true for, + /// so `cost_window_reach` returns `OwnResourcesOnly` and the ONLY thing that + /// can carry the verdict is the mana effect. + /// + /// What that predicate does and does NOT establish — CR 701.21a, quoted from + /// its FIRST sentence: "To sacrifice a permanent, its controller moves it + /// from the battlefield directly to its owner's graveyard. A player can't + /// sacrifice something that isn't a permanent, or something that's a + /// permanent they don't control." Sentence two bounds the actor to + /// permanents they CONTROL — that much is grounded. Sentence one is the half + /// that bounds the conclusion: the card goes to its OWNER's graveyard, so a + /// controlled-but-not-owned Petal (Control Magic) puts a card into another + /// player's graveyard while this leg still answers `OwnResourcesOnly`. + /// `SelfRef` therefore proves control, never ownership, and "confined" is + /// narrower than the predicate's name reads. + /// + /// Not repairable at this seam: `filter_is_actor_owned` is a pure AST + /// predicate and the AST carries no ownership. Nor does it move this row, + /// whose verdict the mana head decides on its own. It is named so the limit + /// is not later widened on the strength of a half-quoted rule — which is the + /// exact species of error (CR 106.4, above) this whole change repairs. + /// + /// A confined cost leg plus a verdict carried by the mana head ALONE is what + /// separates this row from `v9b`'s Ironworks, which reaches `MayInterfere` + /// through an UNPROVEN sacrifice filter and therefore never exercises this + /// arm. + /// + /// Revert-probe, EXECUTED: restoring + /// `Effect::Mana {..} => WindowReach::OwnResourcesOnly` as this match's first + /// arm reds this row at the Dark Ritual verdict, which is where the run + /// aborts. The Lotus Petal verdict flips under the same mutation and that is + /// MEASURED rather than derived — the integration row `v10b` reads exactly + /// this ability through `indexed_ability_window_reach` and flips to `Accept` + /// under the same mutation, which it can only do if this fold returned + /// `OwnResourcesOnly`. + #[test] + fn mana_production_is_reach_not_a_confined_own_resource() { + for (card, index) in [(&DARK_RITUAL, 0usize), (&LOTUS_PETAL, 0usize)] { + assert!( + !head_is_unparsed(card, index), + "VACUITY GUARD: {} must PARSE, or the row measures the fail-closed arm instead \ + of the mana effect's own semantics", + card.name + ); + } + + // PREMISE (card-data is gitignored; re-measured here on the live parser). + let ritual = ability(&DARK_RITUAL, 0); + assert!( + matches!(ritual.effect.as_ref(), Effect::Mana { .. }), + "PREMISE: Dark Ritual heads Effect::Mana; got {:?}", + ritual.effect + ); + assert!( + ritual.cost.is_none(), + "PREMISE: Dark Ritual carries no cost, so NOTHING but the head can carry the verdict" + ); + assert_eq!( + reach(&DARK_RITUAL, 0), + WindowReach::MayInterfere, + "a cast ritual funds an otherwise-unaffordable response" + ); + + let petal = ability(&LOTUS_PETAL, 0); + let cost = petal + .cost + .as_ref() + .expect("PREMISE: Lotus Petal carries an activation cost"); + assert_eq!( + cost_window_reach(cost), + WindowReach::OwnResourcesOnly, + "ATTRIBUTION: the SelfRef sacrifice is proven CONTROLLED (CR 701.21a bounds the actor \ + to permanents they control; the card still leaves for its OWNER's graveyard — see \ + the doc comment), so the cost leg reads confined and cannot be what produces the \ + verdict below" + ); + assert_eq!( + ability_window_reach(&petal), + WindowReach::MayInterfere, + "…so the verdict is carried by the mana effect ALONE — this is the row v9b cannot be" + ); + + // Reach-guard: the classifier can still return OwnResourcesOnly, so the + // two MayInterfere verdicts above are attributable and not a constant. + assert_eq!(reach(&RAMPANT_GROWTH, 0), WindowReach::OwnResourcesOnly); + } + + /// The tap-state gate (CR 110.5b), on the MINIMAL PAIR. `Rampant Growth` and + /// `Nature's Lore` differ by one printed word, and every other axis this + /// classifier reads is identical: no cost, a `SearchLibrary` head with + /// `target_player: None`, and one anaphoric + /// `ChangeZone { Library -> Battlefield, target: Any }` sub-ability. So the + /// verdicts below are attributable to `enter_tapped` and to nothing else — + /// the first two assertions MEASURE that premise rather than assert it. + /// + /// Why the untapped one is reach: the fetched land arrives ready (CR 302.6's + /// summoning-sickness bar is a creature rule), taps for mana inside the + /// window the Shorten hands back, and CR 601.2g runs that mana ability during + /// the cast it funds. `v10c` in `tests/integration/shorten_efficacy.rs` + /// measures that whole chain on the real 4p board; this row pins the AST + /// premise it rests on. + /// + /// Revert-probe: in `effect_window_reach`'s `ChangeZone` arm, replace + /// `object_is_confined && entry_is_confined` with `object_is_confined` alone + /// (the pre-fix expression) — BOTH untapped rows flip to `OwnResourcesOnly`. + #[test] + fn an_untapped_fetch_is_reach_and_a_tapped_one_stays_confined() { + for card in [&NATURES_LORE, &CROP_ROTATION] { + let name = card.name; + assert!( + !head_is_unparsed(card, 0), + "VACUITY GUARD: {name}[0] must PARSE, or the row measures the fail-closed arm \ + instead of the zone-change node's own semantics" + ); + let def = ability(card, 0); + assert!( + def.cost.is_none(), + "PREMISE: {name}[0] carries no cost, so no cost leg can carry the verdict; got \ + {:?}", + def.cost + ); + assert_eq!( + effect_window_reach(&def.effect), + WindowReach::OwnResourcesOnly, + "PREMISE: {name}[0]'s SearchLibrary head is confined ON ITS OWN, so the verdict \ + below is produced by the zone-change sub-ability and by nothing else" + ); + assert_eq!( + reach(card, 0), + WindowReach::MayInterfere, + "{name}[0] puts a land onto the battlefield UNTAPPED (CR 110.5b), which taps for \ + mana inside the window the Shorten hands back — the Effect::Mana case with one \ + extra step" + ); + } + + // The tapped half of the pair, and the reach-guard that keeps the two + // MayInterfere verdicts above from being a constant. + assert_eq!( + reach(&RAMPANT_GROWTH, 0), + WindowReach::OwnResourcesOnly, + "the gate is on the TAP STATE, not on the fetch shape: the same sentence with \ + 'tapped' printed in it stays confined" + ); + + // Fail-closed direction, on all THREE `EtbTapState` variants, measured on + // the REAL parsed node rather than a hand-built one. Only a provably + // `Tapped` entry is allowlisted; `Unspecified` (the AST said nothing) and + // `Untapped` are both reach — which is what makes a conditionally-untapped + // entry (a shock land's pay-life choice, a land-count gate) safe by + // default instead of a shape this predicate would have to model. + let mut node = ability(&NATURES_LORE, 0) + .sub_ability + .as_ref() + .expect("PREMISE: Nature's Lore[0] carries the zone change as its sub_ability") + .effect + .as_ref() + .clone(); + for (state, expected) in [ + (EtbTapState::Tapped, WindowReach::OwnResourcesOnly), + (EtbTapState::Unspecified, WindowReach::MayInterfere), + (EtbTapState::Untapped, WindowReach::MayInterfere), + ] { + let Effect::ChangeZone { enter_tapped, .. } = &mut node else { + panic!("PREMISE: the sub-ability must head a ChangeZone; got {node:?}"); + }; + *enter_tapped = state; + assert_eq!( + effect_window_reach(&node), + expected, + "fail-closed on the tap axis: {state:?} must classify {expected:?}" + ); + } + } + + /// **The origin gate** — a battlefield entry is confined only when the card + /// that arrives comes from a LIBRARY. + /// + /// The three riders in the row below ask what ARRIVES. This one asks what the + /// arriving CARD is, and it is the axis on which `OwnResourcesOnly` was + /// previously asserted over rules text nobody had classified: a graveyard, + /// hand or exile card is a real object in `state.objects`, and neither + /// `object_window_reach`'s `carries_unreadable_rules_content` gate (which + /// runs on the SOURCE) nor `board_observer_may_react` (CR 113.6 — an ETB does + /// not function in a graveyard) ever reads it. + /// + /// **Attribution: exactly one field varies.** Helping Hand's REAL parsed node + /// is measured `MayInterfere`, then the SAME node with `origin` rewritten to + /// `Library` and nothing else touched is measured `OwnResourcesOnly`. Every + /// other axis the arm reads — target filter, tap state, the three riders — is + /// held byte-identical across the pair, so the flip is the origin field's and + /// cannot be the fixture's. + /// + /// The `None` row is the stronger case of the same argument: an absent origin + /// does not even name the zone the card comes from. + #[test] + fn a_battlefield_entry_is_confined_only_from_a_library() { + assert!( + !head_is_unparsed(&HELPING_HAND, 0), + "VACUITY GUARD: Helping Hand[0] must PARSE, or this row measures the fail-closed arm \ + instead of the origin axis" + ); + let node = ability(&HELPING_HAND, 0).effect.as_ref().clone(); + let Effect::ChangeZone { + origin, + destination, + enter_tapped, + target, + .. + } = &node + else { + panic!("PREMISE: Helping Hand[0] must head a ChangeZone; got {node:?}"); + }; + assert_eq!( + (*origin, *destination, *enter_tapped), + ( + Some(Zone::Graveyard), + Zone::Battlefield, + EtbTapState::Tapped + ), + "PREMISE: the real card prints a TAPPED graveyard-to-battlefield return, which is what \ + makes the origin the only axis left to decide the verdict" + ); + assert!( + filter_is_actor_owned(target), + "PREMISE: the target is proven actor-controlled, so `object_is_confined` holds and the \ + verdict below is decided by `entry_is_confined` alone" + ); + + assert_eq!( + effect_window_reach(&node), + WindowReach::MayInterfere, + "a graveyard card entering the battlefield IS a readable object this fold never \ + classified — Accepting here hands back a window in which a Fleshbag-Marauder-class \ + ETB makes every player sacrifice" + ); + + for (label, rewritten) in [ + ( + "Library: the card is an unchosen member of a hidden zone (CR 400.2), so there is \ + nothing to read and nothing is skipped", + Some(Zone::Library), + ), + ("Hand: readable, and never classified", Some(Zone::Hand)), + ("Exile: readable, and never classified", Some(Zone::Exile)), + ( + "None: the seam cannot even name the zone the card comes from", + None, + ), + ] { + let mut mutated = node.clone(); + let Effect::ChangeZone { origin, .. } = &mut mutated else { + unreachable!("just destructured above"); + }; + *origin = rewritten; + let expected = if rewritten == Some(Zone::Library) { + WindowReach::OwnResourcesOnly + } else { + WindowReach::MayInterfere + }; + assert_eq!( + effect_window_reach(&mutated), + expected, + "ONE FIELD VARIES from the node asserted above — {label}" + ); + } + } + + /// PROVABLY tapped, not NOMINALLY tapped — the three riders that decide what + /// actually arrives, plus the split that reaches the battlefield without a + /// `ChangeZone` node at all. + /// + /// Every row mutates ONE field of a REAL parsed node and re-measures, so the + /// unmutated verdict is each row's own positive control: the base node is + /// asserted `OwnResourcesOnly` first, which is what makes each flip + /// attributable to the field and not to the fixture. + /// + /// The corpus is why these are fixtures rather than hypotheticals: MEASURED + /// over every card in the pinned MTGJSON projection, 12 abilities carry a + /// `SearchDestinationSplit`, and NO ability that classifies + /// `OwnResourcesOnly` carries `enters_under`, `enters_attacking` or + /// `enters_modified_if` today. So these guards change no card's verdict at + /// this base — they close the door before a parser improvement or a new card + /// walks through it, in the one direction (toward `MayInterfere`) that can + /// never produce a false Accept. + #[test] + fn a_battlefield_entry_must_be_provably_tapped_actor_controlled_and_not_attacking() { + // ── the three ChangeZone riders, on Terramorphic's real fetch node ── + let base_node = ability(&TERRAMORPHIC, 0) + .sub_ability + .as_ref() + .expect("PREMISE: Terramorphic Expanse[0] carries the zone change as its sub_ability") + .effect + .as_ref() + .clone(); + assert_eq!( + effect_window_reach(&base_node), + WindowReach::OwnResourcesOnly, + "POSITIVE CONTROL: the unmutated tapped fetch node IS confined, so every flip below is \ + attributable to the single field it mutates" + ); + + let mut attacking = base_node.clone(); + let mut foreign = base_node.clone(); + let mut conditional = base_node.clone(); + let Effect::ChangeZone { + enters_attacking, .. + } = &mut attacking + else { + panic!("PREMISE: the fetch node must head a ChangeZone"); + }; + *enters_attacking = true; + let Effect::ChangeZone { enters_under, .. } = &mut foreign else { + panic!("PREMISE: the fetch node must head a ChangeZone"); + }; + *enters_under = Some(ControllerRef::Opponent); + let Effect::ChangeZone { + enters_modified_if, .. + } = &mut conditional + else { + panic!("PREMISE: the fetch node must head a ChangeZone"); + }; + *enters_modified_if = Some(TargetFilter::Any); + + for (label, node) in [ + ( + "enters_attacking (CR 508.4): a tapped attacker is still an attacker", + &attacking, + ), + ( + "enters_under (CR 110.2a): the permanent lands on ANOTHER player's board", + &foreign, + ), + ( + "enters_modified_if (CR 614.12 + CR 614.12a): the tapped rider is CONDITIONAL, so \ + enter_tapped == Tapped is not proof of a tapped entry", + &conditional, + ), + ] { + assert_eq!( + effect_window_reach(node), + WindowReach::MayInterfere, + "{label}" + ); + } + + // ── the split door, on Cultivate's real search node ── + assert!( + !head_is_unparsed(&CULTIVATE, 0), + "VACUITY GUARD: Cultivate[0] must PARSE, or this half measures the fail-closed arm" + ); + let mut search = ability(&CULTIVATE, 0).effect.as_ref().clone(); + let Effect::SearchLibrary { split, .. } = &search else { + panic!("PREMISE: Cultivate[0] must head a SearchLibrary; got {search:?}"); + }; + let split = split.as_ref().expect( + "PREMISE: Cultivate[0] carries a SearchDestinationSplit — this is the shape \ + that reaches the battlefield with no ChangeZone node for the tap gate to read", + ); + assert_eq!( + split.primary_destination, + Zone::Battlefield, + "PREMISE: the split's PRIMARY destination is the battlefield" + ); + assert_eq!( + split.primary_enter_tapped, + EtbTapState::Tapped, + "PREMISE: and the real card prints 'tapped', which is the only reason it stays confined" + ); + assert_eq!( + split.rest_destination, + Zone::Hand, + "PREMISE: and the REST of the search goes to hand — the door this arm used to leave open" + ); + assert_eq!( + effect_window_reach(&search), + WindowReach::MayInterfere, + "the split arm takes the SAME landing-zone authority as the ChangeZone arm: a rest \ + destination of hand is a castable card inside the window, so Cultivate is NOT confined \ + however tapped its primary arrival is" + ); + + // From here the rest destination is moved OFF hand, which is what makes a + // confined split expressible at all: MEASURED on this candidate's own + // projection, all TWELVE `SearchDestinationSplit` carriers route something to + // hand (nine via `rest_destination`, three via `primary_destination`), so no + // real card can serve as the positive control for the tap axis. Single-field + // mutation off a real parsed node is the honest way to get one, and saying so + // is the point — an inert branch presented as covered is the error this row + // exists to avoid. + let Effect::SearchLibrary { + split: Some(split), .. + } = &mut search + else { + panic!("PREMISE: the mutated node must keep its split"); + }; + split.rest_destination = Zone::Graveyard; + assert_eq!( + effect_window_reach(&search), + WindowReach::OwnResourcesOnly, + "CONTROL: one field moved and nothing else, so the verdict above is attributable to \ + `rest_destination` alone and is not a constant" + ); + + for (state, expected) in [ + (EtbTapState::Tapped, WindowReach::OwnResourcesOnly), + (EtbTapState::Unspecified, WindowReach::MayInterfere), + (EtbTapState::Untapped, WindowReach::MayInterfere), + ] { + let Effect::SearchLibrary { + split: Some(split), .. + } = &mut search + else { + panic!("PREMISE: the mutated node must keep its split"); + }; + split.primary_enter_tapped = state; + assert_eq!( + effect_window_reach(&search), + expected, + "the split reaches the battlefield WITHOUT a ChangeZone node, so it takes the same \ + fail-closed tap gate: {state:?} must classify {expected:?}" + ); + } + + let Effect::SearchLibrary { + split: Some(split), .. + } = &mut search + else { + panic!("PREMISE: the mutated node must keep its split"); + }; + split.primary_enter_tapped = EtbTapState::Tapped; + split.rest_destination = Zone::Battlefield; + assert_eq!( + effect_window_reach(&search), + WindowReach::MayInterfere, + "`rest_destination` carries NO tap state of its own, so a battlefield rest can never be \ + proven tapped and is reach by construction" + ); + } + + // ======================================================================= + // PR #7101 — the predicates the integration rows exercise through the real + // board, asserted here at the level the board cannot reach (all three are + // private, and widening them to `pub` for a test would be the wrong trade). + // ======================================================================= + + use crate::types::ability::TriggerDefinition; + use crate::types::triggers::TriggerMode; + + fn trigger(mode: TriggerMode) -> TriggerDefinition { + TriggerDefinition::new(mode) + } + + /// Put one object carrying one trigger on the board and ask the scan. + fn scan_with( + owner: crate::types::player::PlayerId, + zone: Zone, + def: TriggerDefinition, + actor: crate::types::player::PlayerId, + ) -> bool { + let mut state = GameState::default(); + let id = create_object( + &mut state, + crate::types::identifiers::CardId(1), + owner, + "Observer".to_string(), + zone, + ); + state + .objects + .get_mut(&id) + .expect("just created") + .install_trigger_base_definitions(std::sync::Arc::new(vec![def])) + .expect("staging one printed trigger"); + board_observer_may_react(&state, actor) + } + + const ACTOR: crate::types::player::PlayerId = crate::types::player::PlayerId(0); + const OPPONENT: crate::types::player::PlayerId = crate::types::player::PlayerId(1); + + /// **T2.3 — the B4a NEGATIVE CONTROL, and the most load-bearing row here.** + /// + /// The carve-out and [`filter_is_actor_owned`] answer two DIFFERENT questions + /// on the identical input, and the whole [HIGH] fix depends on that gap. + /// `Typed { controller: Some(You) }` is Hedron Crab's `valid_card`; if the + /// carve-out had reused `filter_is_actor_owned` — the obvious reuse, and the + /// one this repo's building-block discipline actively invites — the crab + /// would have been carved out and the finding would have survived the fix. + /// + /// Both directions are asserted, so neither can be a constant: the helper + /// says `true` and the shipped carve-out says "not relieved" on ONE input. + #[test] + fn t2_3_the_carve_out_rejects_the_typed_you_shape_that_filter_is_actor_owned_accepts() { + let hedron_crab_shape = TargetFilter::Typed(crate::types::ability::TypedFilter { + controller: Some(ControllerRef::You), + ..Default::default() + }); + + assert!( + filter_is_actor_owned(&hedron_crab_shape), + "PREMISE: the helper really does accept this shape. If this ever goes false the \ + asymmetry below stops being a hazard and this row stops being a control" + ); + + let mut def = trigger(TriggerMode::ChangesZone); + def.valid_card = Some(hedron_crab_shape); + assert!( + scan_with(OPPONENT, Zone::Battlefield, def, ACTOR), + "THE [HIGH] FIX: an opponent-owned `Typed{{controller: You}}` observer must KEEP the \ + veto. Reusing `filter_is_actor_owned` as the carve-out predicate makes this pass \ + through as relieved — that is the exact defect, and this assertion is what forbids \ + the refactor that reintroduces it" + ); + + let mut self_ref = trigger(TriggerMode::ChangesZone); + self_ref.valid_card = Some(TargetFilter::SelfRef); + assert!( + !scan_with(OPPONENT, Zone::Battlefield, self_ref, ACTOR), + "…while the EXACT `SelfRef` shape is relieved. Without this half the row above would \ + pass under a carve-out that never fires at all" + ); + } + + /// **T2.5 — the relieved-mode table**, one row per family. + /// + /// The integration-level witness for this table is the flagship board itself: + /// Dina, Soul Steeper (`LifeGained`) and Bloodthirsty Conqueror (`LifeLost`) + /// are two of its three zone-gate survivors, and they are relieved by the Life + /// family alone. + /// + /// REVERT-PROBE (executed): delete the Life arm ⇒ those two survive the scan, + /// `board_observer_may_react` returns true for P2, and + /// `v1_live_path_fetchland_seat_accepts_on_the_real_4p_board` flips to + /// `Shorten`. + #[test] + fn t2_5_each_relieved_family_is_relieved_and_its_siblings_are_not() { + let relieved = [ + // CR 119.3 — life. + TriggerMode::LifeGained, + TriggerMode::LifeLost, + TriggerMode::LifeLostAll, + TriggerMode::LifeChanged, + TriggerMode::PayLife, + TriggerMode::PayCumulativeUpkeep, + TriggerMode::PayEcho, + // CR 120.1 — damage. + TriggerMode::DamageDone, + TriggerMode::DamageReceived, + TriggerMode::ExcessDamage, + TriggerMode::Fight, + // CR 508.1 / CR 509.1 — combat turn-based actions. + TriggerMode::Attacks, + TriggerMode::Blocks, + TriggerMode::AttackersDeclared, + TriggerMode::BlockersDeclared, + // CR 500.1 — turn structure. + TriggerMode::Phase, + TriggerMode::TurnBegin, + TriggerMode::NewGame, + // Card flow the allowlist cannot cause. + TriggerMode::Drawn, + TriggerMode::Discarded, + TriggerMode::TokenCreated, + ]; + for mode in relieved { + let def = trigger(mode.clone()); + assert!( + trigger_event_unreachable_by_confined_action(&def), + "{mode:?} must be relieved: no confined action can produce its trigger event" + ); + assert!( + !scan_with(OPPONENT, Zone::Battlefield, trigger(mode.clone()), ACTOR), + "{mode:?} must also be relieved END TO END, through the scan the caller runs" + ); + } + + // The other direction, on modes a confined action really does produce. + for mode in [ + TriggerMode::SpellCast, + TriggerMode::AbilityActivated, + TriggerMode::Taps, + TriggerMode::TapsForMana, + TriggerMode::ManaAdded, + TriggerMode::Shuffled, + TriggerMode::SearchedLibrary, + TriggerMode::PlayerPerformedAction, + TriggerMode::Sacrificed, + TriggerMode::Destroyed, + TriggerMode::Exiled, + TriggerMode::ChangesZone, + TriggerMode::BecomesTarget, + TriggerMode::CounterAdded, + TriggerMode::Revealed, + ] { + assert!( + !trigger_event_unreachable_by_confined_action(&trigger(mode.clone())), + "{mode:?} IS producible by a confined cast or activation, so it must keep its veto" + ); + } + } + + /// **T2.6 — an unclassifiable mode KEEPS its veto.** + /// + /// The fail-closed direction, and the one an earlier draft of this work had + /// backwards. `StateCondition` watches a game STATE rather than an event + /// (CR 603.2), so no event-stream argument can ever relieve it, and + /// `Unknown(_)` is an unclassified Forge mode string by construction. + /// + /// REVERT-PROBE (executed): change the predicate's `_ => false` arm to + /// `_ => true` ⇒ this row fails. + #[test] + fn t2_6_a_mode_the_predicate_cannot_classify_keeps_its_veto() { + for mode in [ + TriggerMode::StateCondition, + TriggerMode::Unknown("SomeFutureForgeMode".to_string()), + // The three MEASURED exclusions from families that read a GENERIC + // `GameEvent::ZoneChanged` an allowlisted `Effect::ChangeZone` emits. + // `match_milled` keys on `ZoneChanged { from: Library, to: Graveyard }` + // (CR 701.17a), which a confined actor-owned self-mill produces. + TriggerMode::Milled, + TriggerMode::MilledAll, + TriggerMode::EntersOrAttacks, + TriggerMode::EntersOrHauntedCreatureDies, + ] { + assert!( + !trigger_event_unreachable_by_confined_action(&trigger(mode.clone())), + "{mode:?} must NOT be relieved — either it is unclassifiable, or its matcher reads \ + a generic zone-change event the confined allowlist really does emit" + ); + } + } + + /// **T2.7 — a disjunctive zone-change trigger is not carved out.** + /// + /// Scrap Trawler's shape: "Whenever this creature dies OR another artifact you + /// control is put into a graveyard from the battlefield, …". When + /// `zone_change_clauses` is non-empty the matcher IGNORES the scalar + /// `valid_card` entirely (`types::ability::TriggerDefinition`), so a `SelfRef` + /// sitting in that field describes only the FIRST clause and says nothing + /// about the second. Carving out on it would relieve a trigger that fires on + /// other objects. + /// + /// Matched pair on the clause list alone. + #[test] + fn t2_7_a_disjunctive_zone_change_trigger_is_not_relieved_by_its_scalar_valid_card() { + let mut scalar = trigger(TriggerMode::ChangesZone); + scalar.valid_card = Some(TargetFilter::SelfRef); + assert!( + !scan_with(OPPONENT, Zone::Battlefield, scalar, ACTOR), + "control: with NO clauses the scalar `valid_card` is what the matcher reads, so the \ + carve-out applies" + ); + + let mut disjunctive = trigger(TriggerMode::ChangesZone); + disjunctive.valid_card = Some(TargetFilter::SelfRef); + disjunctive.zone_change_clauses = vec![crate::types::ability::ZoneChangeClause { + origin: crate::types::ability::OriginConstraint::Equals(Zone::Battlefield), + destination: Some(Zone::Graveyard), + destination_constraint: crate::types::ability::OriginConstraint::any_default(), + valid_card: None, + }]; + assert!( + scan_with(OPPONENT, Zone::Battlefield, disjunctive, ACTOR), + "witness: one clause added and nothing else. The engine now ignores `valid_card`, so \ + reading it would be reading a field that no longer decides anything" + ); + } + + /// **T2.10 — SHAPE PIN.** The relieved set is asserted against `TriggerMode`'s + /// own variant list, so a family that silently widens reds here. + /// + /// The list below is the COMPLETE set of modes this module relieves. A new + /// `TriggerMode` variant defaults to `_ => false` (not relieved) and does not + /// break this row — that is the fail-closed direction and it is correct. What + /// this catches is the dangerous edit: somebody adding a variant to a relieved + /// arm without re-deriving the soundness argument in that arm's comment. + #[test] + fn t2_10_the_relieved_mode_set_is_exactly_this_list() { + let expected: Vec = vec![ + TriggerMode::LifeGained, + TriggerMode::LifeLost, + TriggerMode::LifeLostAll, + TriggerMode::LifeChanged, + TriggerMode::PayLife, + TriggerMode::PayCumulativeUpkeep, + TriggerMode::PayEcho, + TriggerMode::DamageDone, + TriggerMode::DamageDoneOnce, + TriggerMode::DamageAll, + TriggerMode::DamageDealtOnce, + TriggerMode::DamageDoneOnceByController, + TriggerMode::DamageReceived, + TriggerMode::DamagePreventedOnce, + TriggerMode::ExcessDamage, + TriggerMode::ExcessDamageAll, + TriggerMode::Fight, + TriggerMode::FightOnce, + TriggerMode::Attacks, + TriggerMode::AttackersDeclared, + TriggerMode::AttackersDeclaredOneTarget, + TriggerMode::YouAttack, + TriggerMode::YouAttackUnblocked, + TriggerMode::AttackerBlocked, + TriggerMode::AttackerBlockedOnce, + TriggerMode::AttackerBlockedByCreature, + TriggerMode::AttackerUnblocked, + TriggerMode::AttackerUnblockedOnce, + TriggerMode::Blocks, + TriggerMode::BlockersDeclared, + TriggerMode::BecomesBlocked, + TriggerMode::AttacksOrBlocks, + TriggerMode::BlocksOrBecomesBlocked, + TriggerMode::Phase, + TriggerMode::TurnBegin, + TriggerMode::NewGame, + TriggerMode::Drawn, + TriggerMode::Discarded, + TriggerMode::DiscardedAll, + TriggerMode::TokenCreated, + TriggerMode::TokenCreatedOnce, + ]; + + for mode in &expected { + assert!( + trigger_event_unreachable_by_confined_action(&trigger(mode.clone())), + "{mode:?} is in the pinned relieved list but the predicate does not relieve it" + ); + } + + // REVERSE CONTAINMENT, over the enum's own declaration read from source. + // `TriggerMode` derives no iteration trait and there is no name table, so + // the only total enumeration available is the declaration itself; a + // hand-copied list here would be the very drift this row exists to catch. + // Each scanned name goes back through `FromStr`, which is the decoder the + // card pipeline uses. + let source = std::fs::read_to_string(concat!( + env!("CARGO_MANIFEST_DIR"), + "/src/types/triggers.rs" + )) + .expect("the enum's own source file is readable"); + let body = source + .split_once("pub enum TriggerMode {") + .expect("the enum declaration must be findable") + .1; + let body = body.split_once("\n}").expect("…and terminated").0; + + // The names this enumeration cannot construct, PINNED so the gap cannot + // grow silently. Three carry payloads and have no bare-name spelling for + // `FromStr`; the other three are PRE-EXISTING gaps in + // `types::triggers`'s own decoder — they are declared on the enum but have + // no `FromStr` arm, so `from_str` degrades them to `Unknown`. That gap is + // not this module's to fix (and `types/triggers.rs` is outside this + // change), but it IS this row's to disclose: none of the six is in the + // relieved list above, so the reverse containment below covers 165 of the + // enum's 171 variants and this constant names the remaining six. + // Sorted, and compared as a SET: this row's subject is which variants are + // undecodable, not where they sit in the declaration. Pinning declaration + // order would red this `ai_support` row on a no-op reordering of + // `TriggerMode` — a failure that says nothing about either module. + const UNCONSTRUCTIBLE: [&str; 6] = [ + "Copied", // no `FromStr` arm + "Explored", // no `FromStr` arm + "HauntedCreatureDies", // no `FromStr` arm + "KeywordAbilityActivated", // payload + "Planeswalked", // payload + "Unknown", // payload + ]; + let mut undecodable = Vec::new(); + + let mut scanned = 0usize; + let mut unexpected = Vec::new(); + for line in body.lines() { + let line = line.trim(); + // Payload variants FIRST: `Foo(Bar),` would otherwise strip only the + // comma and then fail the identifier filter below, silently dropping + // the variant from the enumeration entirely. + let Some(name) = line + .split_once('(') + .map(|(head, _)| head) + .or_else(|| line.strip_suffix(" {")) + .or_else(|| line.strip_suffix(',')) + else { + continue; + }; + let name = name.trim(); + if name.is_empty() + || !name.chars().next().is_some_and(|c| c.is_ascii_uppercase()) + || !name.chars().all(|c| c.is_ascii_alphanumeric()) + { + continue; + } + let mode: TriggerMode = name.parse().expect("FromStr for TriggerMode is Infallible"); + if matches!(mode, TriggerMode::Unknown(_)) { + undecodable.push(name.to_string()); + continue; + } + scanned += 1; + if trigger_event_unreachable_by_confined_action(&trigger(mode.clone())) + && !expected.contains(&mode) + { + unexpected.push(mode); + } + } + + undecodable.sort_unstable(); + assert_eq!( + undecodable, UNCONSTRUCTIBLE, + "the set of `TriggerMode` variants this enumeration cannot construct changed. If a \ + variant was ADDED to it, the reverse containment below silently stopped covering \ + that variant — restore its `FromStr` arm rather than widening this constant" + ); + assert!( + scanned >= 165, + "the source scan must actually reach the variant list — a parsing change that made it \ + match nothing, or match only a prefix, would turn this whole row vacuous; scanned \ + {scanned}" + ); + assert!( + unexpected.is_empty(), + "these modes are relieved but are NOT in the pinned list — a family widened without \ + its soundness comment being re-derived: {unexpected:?}" + ); + } + + /// **T2.4b — an EMPTY `trigger_zones` means battlefield, not nowhere.** + /// + /// CR 113.6: the zone-of-function question has one authority, + /// `game::triggers::trigger_definition_functions_in_zone`, and the reason the + /// scan must call it rather than read the field is this exact shape. An empty + /// list is the engine's spelling of "battlefield only"; a direct + /// `def.trigger_zones.contains(&zone)` answers `false` for it and would + /// relieve every ordinary battlefield observer on the board — the FAIL-OPEN + /// direction, which produces a false `Accept`. + /// + /// Built here rather than on the real board because it cannot be built there: + /// MEASURED, every active trigger definition on the flagship carries an + /// explicit `trigger_zones: [Battlefield]`, so the direct read and the + /// authority agree across the whole fixture corpus and no integration row can + /// tell them apart. Latent, not live — and closed for the same reason this + /// module closed its `Zone::Stack` arm. + /// + /// REVERT-PROBE (executed): swap the authority call for + /// `def.trigger_zones.contains(&obj.zone)` ⇒ this row fails. + #[test] + fn t2_4b_an_empty_trigger_zones_list_means_battlefield_not_nowhere() { + let mut def = trigger(TriggerMode::ChangesZone); + def.valid_card = Some(TargetFilter::Typed(crate::types::ability::TypedFilter { + controller: Some(ControllerRef::You), + ..Default::default() + })); + assert!( + def.trigger_zones.is_empty(), + "PREMISE: a fresh `TriggerDefinition` carries no zone list, which is the shape under \ + test" + ); + + assert!( + scan_with(OPPONENT, Zone::Battlefield, def.clone(), ACTOR), + "CR 113.6: an empty `trigger_zones` FUNCTIONS on the battlefield, so this observer \ + keeps its veto. A direct `contains` read answers `false` here and relieves it" + ); + assert!( + !scan_with(OPPONENT, Zone::Hand, def, ACTOR), + "…and the same definition does NOT function from hand, so the gate is a distinction \ + and not a constant" + ); + } + + /// **T3.1 — an unread parse warning is not proof of confinement.** + /// + /// RAMPANT_GROWTH is the clean single-variable discriminator: it carries no + /// keywords, no modal, no additional cost, no triggers, no statics and no + /// replacements, so every other disjunct of the gate is already false and the + /// verdict below can only be produced by `parse_warnings`. (Crop Rotation + /// cannot serve — it is over-determined by `additional_cost`.) + /// + /// Both directions on ONE object, one field mutated. + #[test] + fn t3_1_a_parse_diagnostic_gates_an_otherwise_confined_object() { + let mut state = GameState::default(); + let id = create_object( + &mut state, + crate::types::identifiers::CardId(1), + crate::types::player::PlayerId(0), + RAMPANT_GROWTH.name.to_string(), + Zone::Hand, + ); + let parsed = parse_oracle_text(RAMPANT_GROWTH.oracle, RAMPANT_GROWTH.name, &[], &[], &[]); + let obj = state.objects.get_mut(&id).expect("just created"); + obj.abilities = std::sync::Arc::new(parsed.abilities); + + assert_eq!( + object_window_reach(&state, id), + WindowReach::OwnResourcesOnly, + "PREMISE: with a clean parse this card is the module's flagship confined shape. If \ + this direction ever fails the row below stops measuring the diagnostic" + ); + + state + .objects + .get_mut(&id) + .expect("just created") + .parse_warnings = vec![ + crate::parser::oracle_ir::diagnostic::OracleDiagnostic::IgnoredRemainder { + text: "and each opponent loses 2 life".to_string(), + parser: "probe".to_string(), + line_index: 0, + }, + ]; + assert_eq!( + object_window_reach(&state, id), + WindowReach::MayInterfere, + "the parser reported that some printed clause never became an `AbilityDefinition`. \ + Proving confinement from the abilities that DID parse is proving it from the fraction \ + of the card the classifier can see — the module doc's §2 residual, now in band" + ); + } + + /// Urza's Cave, verbatim, BOTH lines (Oracle text verified on Scryfall, + /// `api.scryfall.com/cards/named?exact=Urza's+Cave`). Terramorphic Expanse's + /// UNRESTRICTED sibling on the ACTIVATED path: same `{T}` + sacrifice-this + /// cost shape, same tapped library arrival, and the search filter is + /// `Typed[Land]` where Terramorphic's is `Typed[Land] + HasSupertype(Basic)`. + /// Ability `[0]` is its mana ability and `[1]` is the fetch, which is what + /// makes it the right card for the INDEXED entry point: folding the whole + /// object would be over-determined by `Effect::Mana`. + const URZAS_CAVE: Card = Card { + name: "Urza's Cave", + oracle: "{T}: Add {C}.\n{3}, {T}, Sacrifice this land: Search your library for a land \ + card, put it onto the battlefield tapped, then shuffle.", + }; + + /// Bojuka Bog, verbatim (Oracle text verified on Scryfall, + /// `api.scryfall.com/cards/named?exact=Bojuka+Bog`). A LAND whose ETB reaches + /// a graveyard belonging to somebody else — the card that makes "an + /// unrestricted land search is confined" false. + const BOJUKA_BOG: Card = Card { + name: "Bojuka Bog", + oracle: "This land enters tapped.\nWhen this land enters, exile target player's \ + graveyard.\n{T}: Add {B}.", + }; + + /// Snow-Covered Swamp, verbatim — its entire printed rules text is one mana + /// ability. The inert control, and a REAL basic rather than a hand-built + /// vanilla stand-in. + const SNOW_COVERED_SWAMP: Card = Card { + name: "Snow-Covered Swamp", + oracle: "({T}: Add {B}.)", + }; + + /// Put a real parsed card into `player`'s library through the production face + /// path, so its trigger/replacement/static/keyword content is whatever the + /// pipeline actually produces rather than whatever a fixture remembered to + /// set. + fn give_library_card( + state: &mut GameState, + player: PlayerId, + card: &Card, + supertypes: Vec, + ) -> ObjectId { + let parsed = parse_oracle_text(card.oracle, card.name, &[], &[], &[]); + let face = CardFace { + name: card.name.to_string(), + oracle_text: Some(card.oracle.to_string()), + card_type: crate::types::card_type::CardType { + supertypes, + core_types: vec![crate::types::card_type::CoreType::Land], + subtypes: vec![], + }, + abilities: parsed.abilities, + triggers: parsed.triggers, + static_abilities: parsed.statics, + replacements: parsed.replacements, + keywords: parsed.extracted_keywords, + parse_warnings: parsed.parse_warnings, + ..CardFace::default() + }; + let id = create_object( + state, + crate::types::identifiers::CardId(state.next_object_id), + player, + card.name.to_string(), + Zone::Library, + ); + crate::game::printed_cards::apply_card_face_to_object( + state.objects.get_mut(&id).expect("just created"), + &face, + ); + id + } + + /// **T3.3 — a tapped library fetch is confined only while every card it could + /// SELECT is inert, at BOTH entry points.** + /// + /// `effect_window_reach`'s `ChangeZone` arm admits a tapped battlefield + /// arrival whose origin is a library, and it used to discharge the arriving + /// card's rules content with a hidden-zone argument: CR 400.2 makes a library + /// hidden, so "the card is the subject of neither input". CR 701.23a says + /// otherwise — "To search for a card in a zone, look at all cards in that zone + /// (even if it's a hidden zone) and find a card that matches the given + /// description" — and the library cards really are in `state.objects`. + /// + /// TWO AXES, varied one at a time against the same board: + /// * **the deck** — Urza's Cave / Reshape the Earth search for ANY land, so a + /// Bojuka Bog in the library is selectable and a Snow-Covered Swamp is not + /// enough to save it; + /// * **the printed filter** — Terramorphic Expanse searches for a BASIC land + /// on the identical library, and the Bog is not basic, so it stays confined. + /// This is the row that separates the implemented gate from a + /// "Basic means safe" heuristic: the heuristic passes the filter axis and + /// fails the deck axis. + /// + /// Both entry points are exercised because they gate independently: + /// `object_window_reach` folds every ability (Reshape the Earth, a spell), and + /// `indexed_ability_window_reach` folds exactly one (Urza's Cave `[1]`, an + /// activation). Dropping the conjunct from either one alone leaves the other + /// row green. + /// + /// REVERT-PROBES (both executed): delete the `library_arrivals_are_inert` + /// conjunct from `object_window_reach` ⇒ the Reshape row's `MayInterfere` + /// flips; delete it from `indexed_ability_window_reach` ⇒ the Urza's Cave + /// row's flips. + #[test] + fn t3_3_a_library_fetch_is_confined_only_while_every_selectable_card_is_inert() { + const RESHAPE_THE_EARTH: Card = Card { + name: "Reshape the Earth", + oracle: "Search your library for up to ten land cards, put them onto the battlefield \ + tapped, then shuffle.", + }; + let actor = crate::types::player::PlayerId(0); + + // One board, built once, then two sources staged on it. + let mut state = GameState::default(); + state.players.push(crate::types::player::Player { + id: actor, + ..Default::default() + }); + let swamp = give_library_card( + &mut state, + actor, + &SNOW_COVERED_SWAMP, + vec![ + crate::types::card_type::Supertype::Basic, + crate::types::card_type::Supertype::Snow, + ], + ); + let bog = give_library_card(&mut state, actor, &BOJUKA_BOG, vec![]); + for id in [swamp, bog] { + state + .players + .iter_mut() + .find(|p| p.id == actor) + .expect("seated") + .library + .push_back(id); + } + assert!( + !carries_unreadable_rules_content(&state.objects[&swamp]), + "PREMISE: a basic Snow-Covered Swamp is inert, so it can never be what produces a \ + `MayInterfere` below" + ); + assert!( + carries_unreadable_rules_content(&state.objects[&bog]), + "PREMISE: Bojuka Bog carries rules content this fold cannot read — its ETB. Without \ + this the rows below have no hazard to detect" + ); + + // ── source A: a SPELL, folded through `object_window_reach` ── + let reshape = create_object( + &mut state, + crate::types::identifiers::CardId(90), + actor, + RESHAPE_THE_EARTH.name.to_string(), + Zone::Hand, + ); + state + .objects + .get_mut(&reshape) + .expect("just created") + .abilities = std::sync::Arc::new(vec![ability(&RESHAPE_THE_EARTH, 0)]); + assert_eq!( + object_window_reach(&state, reshape), + WindowReach::MayInterfere, + "an unrestricted land search LOOKS AT ALL CARDS (CR 701.23a) and this library holds \ + Bojuka Bog, whose ETB exiles a graveyard the actor does not own" + ); + + // ── source B: an ACTIVATION, folded through `indexed_ability_window_reach` ── + let cave = create_object( + &mut state, + crate::types::identifiers::CardId(91), + actor, + URZAS_CAVE.name.to_string(), + Zone::Battlefield, + ); + state + .objects + .get_mut(&cave) + .expect("just created") + .abilities = + std::sync::Arc::new(vec![ability(&URZAS_CAVE, 0), ability(&URZAS_CAVE, 1)]); + assert_eq!( + indexed_ability_window_reach(&state, cave, 1), + WindowReach::MayInterfere, + "the same hazard reached through the ACTIVATED entry point, which gates separately" + ); + + // ── AXIS 1: vary the DECK, hold the cards fixed ── + let inert_library = { + let mut inert = state.clone(); + inert.objects.remove(&bog); + inert + .players + .iter_mut() + .find(|p| p.id == actor) + .expect("seated") + .library + .retain(|id| *id != bog); + inert + }; + assert_eq!( + object_window_reach(&inert_library, reshape), + WindowReach::OwnResourcesOnly, + "AXIS 1 (deck varies): the identical unrestricted search is confined once every land \ + it could select is inert. Without this direction the gate could be a constant" + ); + assert_eq!( + indexed_ability_window_reach(&inert_library, cave, 1), + WindowReach::OwnResourcesOnly, + "AXIS 1, activated path" + ); + + // ── AXIS 2: vary the printed FILTER, hold the deck fixed ── + let terramorphic = create_object( + &mut state, + crate::types::identifiers::CardId(92), + actor, + TERRAMORPHIC.name.to_string(), + Zone::Battlefield, + ); + state + .objects + .get_mut(&terramorphic) + .expect("just created") + .abilities = std::sync::Arc::new(vec![ability(&TERRAMORPHIC, 0)]); + assert_eq!( + object_window_reach(&state, terramorphic), + WindowReach::OwnResourcesOnly, + "AXIS 2 (filter varies, deck held): a BASIC land search on the very library that \ + defeats the unrestricted one stays confined — the Bog is not basic. A gate that read \ + 'is there a fetch' rather than 'what can it select' fails here" + ); + } + + /// **T3.4 — a non-empty selection filter that matches nothing fails closed.** + /// + /// The one place [`library_arrivals_are_inert`] refuses to answer instead of + /// answering. A library with cards in it but none matching the search could be + /// a deck that genuinely cannot fetch, or it could be a filter this seam + /// cannot evaluate — `FilterContext::from_source_with_controller` carries the + /// acting object but no ability instance, and MEASURED over + /// `data/card-data.json` 24 cards carry a `SearchLibrary` filter that reads + /// the ability's own target (`SameNameAsParentTarget`, `CanEnchant { + /// ParentTarget }`). Those two cases are indistinguishable here, and one of + /// them is a false `Accept`, so neither gets one. + /// + /// The row is the pair: an EMPTY library is confined (nothing can be + /// selected — the filter never ran), a NON-EMPTY library with no match is not. + /// + /// REVERT-PROBE (executed): change the `selectable.is_empty()` arm to return + /// `true` ⇒ the second assertion flips to `OwnResourcesOnly`. + #[test] + fn t3_4_a_search_filter_that_matches_nothing_is_unanswerable_not_confined() { + const RAMPANT_GROWTH: Card = Card { + name: "Rampant Growth", + oracle: "Search your library for a basic land card, put that card onto the \ + battlefield tapped, then shuffle.", + }; + let actor = crate::types::player::PlayerId(0); + let mut state = GameState::default(); + state.players.push(crate::types::player::Player { + id: actor, + ..Default::default() + }); + let growth = create_object( + &mut state, + crate::types::identifiers::CardId(1), + actor, + RAMPANT_GROWTH.name.to_string(), + Zone::Hand, + ); + state + .objects + .get_mut(&growth) + .expect("just created") + .abilities = std::sync::Arc::new(vec![ability(&RAMPANT_GROWTH, 0)]); + + assert_eq!( + object_window_reach(&state, growth), + WindowReach::OwnResourcesOnly, + "an EMPTY library selects nothing, so nothing arrives. This empty set IS an answer" + ); + + // One NON-basic land in the library: the pool is non-empty and the + // basic-land filter matches none of it. + let bog = give_library_card(&mut state, actor, &BOJUKA_BOG, vec![]); + state + .players + .iter_mut() + .find(|p| p.id == actor) + .expect("seated") + .library + .push_back(bog); + assert_eq!( + object_window_reach(&state, growth), + WindowReach::MayInterfere, + "a non-empty pool that the filter matched NOTHING in is the case this seam cannot \ + tell apart from a filter it is unable to evaluate, so it does not claim confinement" + ); + } + + /// **T3.2 — a transform carries the DISPLAYED face's parse diagnostics, both + /// ways.** + /// + /// `obj.parse_warnings` documents itself as "diagnostics for the displayed + /// face", and before `BackFaceData` carried the field that sentence was false + /// the moment a permanent transformed. `printed_cards`' face application + /// copies field by field, so the FRONT face's diagnostics stayed on the object + /// while the BACK face's rules text was displayed. Both directions are wrong + /// and they are wrong in opposite ways, which is why both are asserted here: + /// a front-clean/back-dirty card looked clean while showing text the parser + /// could not read, and a front-dirty/back-clean one kept a diagnostic that + /// described nothing on the object any more. + /// + /// Driven through the REAL `game::transform::transform_permanent`, not through + /// the two face helpers it calls — a test that called `snapshot_object_face` + /// and `apply_back_face_to_object` itself would go green even if the transform + /// stopped using them. + /// + /// **What this row does NOT claim.** It is not a `carries_unreadable_rules_content` + /// row, and asserting that gate here would be vacuous: the gate has a + /// `back_face.is_some()` disjunct, so EVERY double-faced object trips it + /// whatever its diagnostics say. The claim is about the state the gate (and + /// `game::visibility`'s two redactions) read — that after a transform the + /// field describes the face now on top. + /// + /// REVERT-PROBES (both executed): + /// * drop `obj.parse_warnings = back_face.parse_warnings` from + /// `printed_cards::apply_back_face_to_object` ⇒ the post-transform assertion + /// fails in BOTH cases; + /// * drop `parse_warnings: obj.parse_warnings.clone()` from + /// `printed_cards::snapshot_object_face` ⇒ the transform-back assertion + /// fails in the front-dirty case (the outgoing face's diagnostics are lost + /// rather than stashed). + #[test] + fn t3_2_a_transform_carries_the_displayed_faces_parse_diagnostics() { + use crate::game::printed_cards::{apply_card_face_to_back_face, apply_card_face_to_object}; + + fn diagnostic(text: &str) -> crate::parser::oracle_ir::diagnostic::OracleDiagnostic { + crate::parser::oracle_ir::diagnostic::OracleDiagnostic::IgnoredRemainder { + text: text.to_string(), + parser: "probe".to_string(), + line_index: 0, + } + } + + // Both directions, as a table: neither verdict can be a constant. + for (label, front_warnings, back_warnings) in [ + ("front-clean / back-dirty", vec![], vec![diagnostic("back")]), + ( + "front-dirty / back-clean", + vec![diagnostic("front")], + vec![], + ), + ] { + let mut state = GameState::default(); + let id = create_object( + &mut state, + crate::types::identifiers::CardId(1), + crate::types::player::PlayerId(0), + "Probe Front".to_string(), + Zone::Battlefield, + ); + + let front = CardFace { + name: "Probe Front".to_string(), + parse_warnings: front_warnings.clone(), + ..CardFace::default() + }; + let back = CardFace { + name: "Probe Back".to_string(), + parse_warnings: back_warnings.clone(), + ..CardFace::default() + }; + apply_card_face_to_object(state.objects.get_mut(&id).expect("just created"), &front); + let mut stored_back = crate::game::specialize::empty_back_face(); + apply_card_face_to_back_face(&mut stored_back, &back); + assert_eq!( + stored_back.parse_warnings, back_warnings, + "PREMISE ({label}): `apply_card_face_to_back_face` must carry the face's own \ + diagnostics, or the transform below has nothing to install" + ); + state.objects.get_mut(&id).expect("just created").back_face = Some(stored_back); + + assert_eq!( + state.objects[&id].parse_warnings, front_warnings, + "PREMISE ({label}): the object starts on its FRONT face" + ); + + let mut events = Vec::new(); + crate::game::transform::transform_permanent(&mut state, id, &mut events) + .expect("CR 701.27a: a battlefield permanent with a back face transforms"); + assert!( + state.objects[&id].transformed, + "PREMISE ({label}): the transform must actually have happened, or the assertion \ + below is comparing the front face to itself" + ); + assert_eq!( + state.objects[&id].parse_warnings, back_warnings, + "({label}) the displayed face is now the BACK face, so its diagnostics are the \ + object's. Reporting the front face's here is reporting evidence about text the \ + permanent no longer has" + ); + + crate::game::transform::transform_permanent(&mut state, id, &mut events) + .expect("CR 701.27a: and it transforms back"); + assert!( + !state.objects[&id].transformed, + "PREMISE ({label}): the round trip must return to the front face" + ); + assert_eq!( + state.objects[&id].parse_warnings, front_warnings, + "({label}) the return trip restores the FRONT face's diagnostics exactly — a \ + stale back-face warning would outlive the text it described" + ); + + // THE THIRD SNAPSHOT HELPER. `game::effects::turn_face_down` stashes + // `snapshot_object_base_face` and the face-up path installs it with + // the same `apply_back_face_to_object` the transform above used, so + // that restore now READS a field the base snapshot has to write. + // MEASURED: `morph::apply_face_down_creature_characteristics` blanks + // name, types, abilities, keywords and every definition list but never + // touches `parse_warnings`, so a base snapshot that dropped the field + // would make turning face UP clear diagnostics that turning face DOWN + // had left alone — a regression this change would have introduced. + // + // Helper-level on purpose and stated as such: the production entry is + // an effect resolver needing a `ResolvedAbility` and a legal morph + // cost, and those would become the variables the row measures. + // REVERT-PROBE (executed): replace `snapshot_object_base_face`'s + // `parse_warnings: obj.parse_warnings.clone()` with `Vec::new()` ⇒ + // this assertion fails in the front-dirty case. + let stashed = + crate::game::printed_cards::snapshot_object_base_face(&state.objects[&id]); + crate::game::printed_cards::apply_back_face_to_object( + state.objects.get_mut(&id).expect("just created"), + stashed, + ); + assert_eq!( + state.objects[&id].parse_warnings, front_warnings, + "({label}) the face-down stash/restore pair must round-trip the displayed face's \ + diagnostics too" + ); + } + } +} diff --git a/crates/engine/src/bin/interaction_bindings.rs b/crates/engine/src/bin/interaction_bindings.rs index 7cf60696bc..2ade424c1c 100644 --- a/crates/engine/src/bin/interaction_bindings.rs +++ b/crates/engine/src/bin/interaction_bindings.rs @@ -21,7 +21,7 @@ use engine::types::interaction::{ }; use ts_rs::{Config, TS}; -const HEADER: &str = "// @generated by `cargo run -p engine --features interaction-bindings --bin interaction-bindings -- --write`.\n// Do not edit by hand.\n\n"; +const HEADER: &str = "// @generated by `cargo run -p phase-engine --features interaction-bindings --bin interaction-bindings -- --write`.\n// Do not edit by hand.\n\n"; fn export_decl(config: &Config) -> String { let declaration = T::decl(config); diff --git a/crates/engine/src/game/casting_tests.rs b/crates/engine/src/game/casting_tests.rs index 2b3a34402a..b8ce7bc4bc 100644 --- a/crates/engine/src/game/casting_tests.rs +++ b/crates/engine/src/game/casting_tests.rs @@ -10574,6 +10574,7 @@ fn hearth_elemental_self_cost_reduction_counts_adventures() { casting_restrictions: vec![], casting_options: vec![], layout_kind: Some(crate::types::card::LayoutKind::Adventure), + parse_warnings: vec![], }); } } @@ -26689,6 +26690,7 @@ fn create_adventure_in_hand(state: &mut GameState, player: PlayerId) -> ObjectId casting_restrictions: Vec::new(), casting_options: Vec::new(), layout_kind: None, + parse_warnings: vec![], }); obj_id @@ -26781,6 +26783,7 @@ fn create_enchantment_adventure_in_hand(state: &mut GameState, player: PlayerId) casting_restrictions: Vec::new(), casting_options: Vec::new(), layout_kind: Some(LayoutKind::Adventure), + parse_warnings: vec![], }); obj_id @@ -26866,6 +26869,7 @@ fn create_omen_in_hand(state: &mut GameState, player: PlayerId) -> ObjectId { casting_restrictions: Vec::new(), casting_options: Vec::new(), layout_kind: Some(LayoutKind::Omen), + parse_warnings: vec![], }); obj_id @@ -30680,6 +30684,7 @@ fn add_disturb_creature_to_graveyard( casting_restrictions: Vec::new(), casting_options: Vec::new(), layout_kind: Some(LayoutKind::Transform), + parse_warnings: vec![], }); obj_id } @@ -36967,6 +36972,7 @@ mod mtmte_cast_flow { casting_restrictions: Vec::new(), casting_options: Vec::new(), layout_kind: Some(LayoutKind::Transform), + parse_warnings: vec![], } } @@ -50977,6 +50983,7 @@ fn exact_resolution_offer_does_not_inherit_sibling_cast_transformed() { casting_restrictions: Vec::new(), casting_options: Vec::new(), layout_kind: Some(LayoutKind::Transform), + parse_warnings: vec![], }); obj.casting_permissions .push(CastingPermission::ExileWithAltCost { diff --git a/crates/engine/src/game/combat.rs b/crates/engine/src/game/combat.rs index bd8494ba74..0e43a938f7 100644 --- a/crates/engine/src/game/combat.rs +++ b/crates/engine/src/game/combat.rs @@ -14656,7 +14656,7 @@ mod tests { /// `compute_combat_tax` hot path after #4312/#4329: go-wide attackers /// against several active tax statics. Run explicitly with: /// - /// `cargo test -p engine combat_tax_profile_gate_go_wide_board -- --ignored --nocapture` + /// `cargo test -p phase-engine combat_tax_profile_gate_go_wide_board -- --ignored --nocapture` #[test] #[ignore = "perf benchmark; run manually"] fn combat_tax_profile_gate_go_wide_board() { diff --git a/crates/engine/src/game/coverage.rs b/crates/engine/src/game/coverage.rs index 752df35b28..ee446f9006 100644 --- a/crates/engine/src/game/coverage.rs +++ b/crates/engine/src/game/coverage.rs @@ -26,7 +26,7 @@ use crate::types::ability::{ QuantityExpr, QuantityRef, ReplacementCondition, ReplacementDefinition, ReplacementMode, SeatDirection, SharedQuality, SharedQualityRelation, SpeedDelta, SpellCastingOption, SpellCastingOptionKind, SpellStackToGraveyardReplacement, StaticCondition, StaticDefinition, - TapStateChange, TargetFilter, TriggerDefinition, TypeFilter, TypedFilter, ZoneRef, + TapStateChange, TargetFilter, TriggerDefinition, TypeFilter, TypedFilter, VoteSubject, ZoneRef, }; use crate::types::card::CardFace; use crate::types::card_type::CoreType; @@ -4816,37 +4816,9 @@ fn build_ability_item(def: &AbilityDefinition) -> ParsedItem { children.push(build_ability_item(mode_ability)); } - // CR 705.2 (#5601): coin-flip branch effects are embedded `AbilityDefinition`s - // (not `sub_ability` links), so — like the sub-ability / else / modal chains - // above — recurse into them here. Without this the win/lose branch parse - // signatures are swallowed by the bare `("win"/"lose", "yes")` presence - // markers in `effect_details`, making a real parser change inside a branch - // (e.g. Desperate Gambit's lose-branch `damage_source_filter` flipping - // `SelfRef` → `ChosenDamageSource`) invisible to the coverage parse-diff. - // Same swallowed-structure class as #5492/#5495/#5501. - match &*def.effect { - Effect::FlipCoin { - win_effect, - lose_effect, - .. - } - | Effect::FlipCoins { - win_effect, - lose_effect, - .. - } => { - if let Some(win) = win_effect { - children.push(build_ability_item(win)); - } - if let Some(lose) = lose_effect { - children.push(build_ability_item(lose)); - } - } - Effect::FlipCoinUntilLose { win_effect } => { - children.push(build_ability_item(win_effect)); - } - _ => {} - } + visit_direct_effect_ability_payloads(&def.effect, |_, payload| { + children.push(build_ability_item(payload)); + }); ParsedItem { category: ParseCategory::Ability, @@ -6257,6 +6229,341 @@ fn visit_face_modifications(face: &CardFace, visit: &mut impl FnMut(&ContinuousM } } +/// Direct `Effect` fields that carry executable ability definitions. +/// +/// These payloads are neither ordinary ability chains nor grants inside a +/// `GenericEffect`. Consumers that need to inspect an ability tree use this +/// enumeration in addition to their existing traversal for those separate +/// structures. +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +enum DirectEffectPayloadEdge { + VotePerChoice, + VoteObjectOutcome, + SeparateIntoPilesChosen, + SeparateIntoPilesUnchosen, + RevealFromHandOnDecline, + CreateDelayedTriggerEffect, + RollDieResult, + FlipCoinWin, + FlipCoinLose, + FlipCoinsWin, + FlipCoinsLose, + FlipCoinUntilLoseWin, + ChooseOneOfBranch, +} + +/// Visits the one-level executable ability payloads embedded directly in an effect. +fn visit_direct_effect_ability_payloads<'a>( + effect: &'a Effect, + mut visit: impl FnMut(DirectEffectPayloadEdge, &'a AbilityDefinition), +) { + match effect { + Effect::Vote { + per_choice_effect, + subject, + .. + } => { + for effect in per_choice_effect { + visit(DirectEffectPayloadEdge::VotePerChoice, effect); + } + if let VoteSubject::Objects { + outcome_template, .. + } = subject + { + visit(DirectEffectPayloadEdge::VoteObjectOutcome, outcome_template); + } + } + Effect::SeparateIntoPiles { + chosen_pile_effect, + unchosen_pile_effect, + .. + } => { + visit( + DirectEffectPayloadEdge::SeparateIntoPilesChosen, + chosen_pile_effect, + ); + if let Some(unchosen_pile_effect) = unchosen_pile_effect { + visit( + DirectEffectPayloadEdge::SeparateIntoPilesUnchosen, + unchosen_pile_effect, + ); + } + } + Effect::RevealFromHand { + on_decline: Some(on_decline), + .. + } => { + visit(DirectEffectPayloadEdge::RevealFromHandOnDecline, on_decline); + } + Effect::CreateDelayedTrigger { effect, .. } => { + visit(DirectEffectPayloadEdge::CreateDelayedTriggerEffect, effect); + } + Effect::RollDie { results, .. } => { + for result in results { + visit(DirectEffectPayloadEdge::RollDieResult, &result.effect); + } + } + Effect::FlipCoin { + win_effect, + lose_effect, + .. + } => { + if let Some(win_effect) = win_effect { + visit(DirectEffectPayloadEdge::FlipCoinWin, win_effect); + } + if let Some(lose_effect) = lose_effect { + visit(DirectEffectPayloadEdge::FlipCoinLose, lose_effect); + } + } + Effect::FlipCoins { + win_effect, + lose_effect, + .. + } => { + if let Some(win_effect) = win_effect { + visit(DirectEffectPayloadEdge::FlipCoinsWin, win_effect); + } + if let Some(lose_effect) = lose_effect { + visit(DirectEffectPayloadEdge::FlipCoinsLose, lose_effect); + } + } + Effect::FlipCoinUntilLose { win_effect } => { + visit(DirectEffectPayloadEdge::FlipCoinUntilLoseWin, win_effect); + } + Effect::ChooseOneOf { branches, .. } => { + for branch in branches { + visit(DirectEffectPayloadEdge::ChooseOneOfBranch, branch); + } + } + // Keep this exhaustive: direct ability payloads must be classified above + // when a new `Effect` variant is introduced. + Effect::StartYourEngines { .. } + | Effect::ChangeSpeed { .. } + | Effect::DealDamage { .. } + | Effect::ApplyPostReplacementDamage { .. } + | Effect::EachDealsDamageEqualToPower { .. } + | Effect::EachSourceDealsDamage { .. } + | Effect::Draw { .. } + | Effect::Pump { .. } + | Effect::PairWith { .. } + | Effect::Destroy { .. } + | Effect::Regenerate { .. } + | Effect::RemoveAllDamage { .. } + | Effect::Counter { .. } + | Effect::CounterAll { .. } + | Effect::Token { .. } + | Effect::GainLife { .. } + | Effect::LoseLife { .. } + | Effect::SetTapState { .. } + | Effect::RemoveCounter { .. } + | Effect::Sacrifice { .. } + | Effect::DiscardCard { .. } + | Effect::Mill { .. } + | Effect::Scry { .. } + | Effect::PumpAll { .. } + | Effect::DamageAll { .. } + | Effect::DamageEachPlayer { .. } + | Effect::DestroyAll { .. } + | Effect::ChangeZone { .. } + | Effect::ChangeZoneAll { .. } + | Effect::Dig { .. } + | Effect::GainControl { .. } + | Effect::GainControlAll { .. } + | Effect::ControlNextTurn { .. } + | Effect::Attach { .. } + | Effect::UnattachAll { .. } + | Effect::Surveil { .. } + | Effect::Fight { .. } + | Effect::Bounce { .. } + | Effect::BounceAll { .. } + | Effect::Explore + | Effect::ExploreAll { .. } + | Effect::Investigate + | Effect::Tribute { .. } + | Effect::TimeTravel + | Effect::BecomeMonarch + | Effect::NoOp + | Effect::Proliferate + | Effect::ProliferateTarget { .. } + | Effect::Populate + | Effect::Clash + | Effect::Behold { .. } + | Effect::EndTheTurn + | Effect::EndCombatPhase + | Effect::SwitchPT { .. } + | Effect::CopySpell { .. } + | Effect::EpicCopy { .. } + | Effect::CastCopyOfCard { .. } + | Effect::CopyTokenOf { .. } + | Effect::CreateTokenCopyFromPool { .. } + | Effect::Myriad + | Effect::Encore + | Effect::CombineHost { .. } + | Effect::ChooseAugmentAndCombineWithHost { .. } + | Effect::Meld { .. } + | Effect::ExileHaunting { .. } + | Effect::HideawayConceal { .. } + | Effect::CopyTokenBlockingAttacker { .. } + | Effect::BecomeCopy { .. } + | Effect::ChoosePermanent { .. } + | Effect::GainActivatedAbilitiesOfTarget { .. } + | Effect::ChooseCard { .. } + | Effect::PutCounter { .. } + | Effect::ChooseCounterKind { .. } + | Effect::PutChosenCounter { .. } + | Effect::PutCounterAll { .. } + | Effect::MultiplyCounter { .. } + | Effect::ChooseCounterAdjustment { .. } + | Effect::DoublePT { .. } + | Effect::DoublePTAll { .. } + | Effect::MoveCounters { .. } + | Effect::ReproduceEventCounters { .. } + | Effect::Animate { .. } + | Effect::ReturnAsAura { .. } + | Effect::RegisterBending { .. } + | Effect::GenericEffect { .. } + | Effect::Cleanup { .. } + | Effect::Mana { .. } + | Effect::Discard { .. } + | Effect::Shuffle { .. } + | Effect::Transform { .. } + | Effect::FlipPermanent { .. } + | Effect::SearchLibrary { .. } + | Effect::SearchOutsideGame { .. } + | Effect::RevealHand { .. } + | Effect::RevealFromHand { + on_decline: None, .. + } + | Effect::Reveal { .. } + | Effect::RevealTop { .. } + | Effect::ExileTop { .. } + | Effect::ExileFaceDownPile { .. } + | Effect::TargetOnly { .. } + | Effect::Choose { .. } + | Effect::OpponentGuess { .. } + | Effect::SwapChosenLabels { .. } + | Effect::ChooseDamageSource { .. } + | Effect::Suspect { .. } + | Effect::Unsuspect { .. } + | Effect::Connive { .. } + | Effect::PhaseOut { .. } + | Effect::PhaseIn { .. } + | Effect::ForceBlock { .. } + | Effect::ForceAttack { .. } + | Effect::SolveCase + | Effect::BecomePrepared { .. } + | Effect::BecomeUnprepared { .. } + | Effect::BecomeSaddled { .. } + | Effect::SetClassLevel { .. } + | Effect::AddTargetReplacement { .. } + | Effect::AddRestriction { .. } + | Effect::ReduceNextSpellCost { .. } + | Effect::GrantNextSpellAbility { .. } + | Effect::AddPendingETBCounters { .. } + | Effect::AddPendingEntersModifications { .. } + | Effect::CreateEmblem { .. } + | Effect::PayCost { .. } + | Effect::CastFromZone { .. } + | Effect::FreeCastFromZones { .. } + | Effect::ExileResolvingSpellInsteadOfGraveyard { .. } + | Effect::PreventDamage { .. } + | Effect::CreateDamageReplacement { .. } + | Effect::CreateDrawReplacement { .. } + | Effect::CreatePlaneswalkReplacement { .. } + | Effect::LoseTheGame { .. } + | Effect::WinTheGame { .. } + | Effect::RingTemptsYou + | Effect::VentureIntoDungeon + | Effect::VentureInto { .. } + | Effect::TakeTheInitiative + | Effect::ArrangePlanarDeckTop { .. } + | Effect::Planeswalk + | Effect::ChaosEnsues + | Effect::ReverseTurnOrder + | Effect::RedistributeLifeTotals + | Effect::OpenAttractions { .. } + | Effect::RollToVisitAttractions + | Effect::AssembleContraptions { .. } + | Effect::AssembleContraptionsFromRollDifference + | Effect::CrankContraptions { .. } + | Effect::ReassembleContraption { .. } + | Effect::AssembleContraptionOnSprocket { .. } + | Effect::ReassembleContraptionOnSprocket { .. } + | Effect::PutSticker { .. } + | Effect::ApplySticker { .. } + | Effect::ProcessRadCounters + | Effect::GrantCastingPermission { .. } + | Effect::ChooseFromZone { .. } + | Effect::RememberCard { .. } + | Effect::NoteManaSpent + | Effect::ForEachCategory { .. } + | Effect::ChooseObjectsIntoTrackedSet { .. } + | Effect::ChooseAndSacrificeRest { .. } + | Effect::EachPlayerCopyChosen { .. } + | Effect::Exploit { .. } + | Effect::GainEnergy { .. } + | Effect::GivePlayerCounter { .. } + | Effect::LoseAllPlayerCounters { .. } + | Effect::ExileFromTopUntil { .. } + | Effect::RevealUntil { .. } + | Effect::Discover { .. } + | Effect::Heist { .. } + | Effect::HeistExile + | Effect::Cascade + | Effect::Ripple { .. } + | Effect::MiracleCast { .. } + | Effect::MadnessCast { .. } + | Effect::PutAtLibraryPosition { .. } + | Effect::ChooseDrawnThisTurnPayOrTopdeck { .. } + | Effect::PutOnTopOrBottom { .. } + | Effect::GiftDelivery { .. } + | Effect::Goad { .. } + | Effect::GoadAll { .. } + | Effect::Detain { .. } + | Effect::SetRoomDoorLock { .. } + | Effect::ExchangeControl { .. } + | Effect::ChangeTargets { .. } + | Effect::Manifest { .. } + | Effect::ManifestDread + | Effect::Cloak { .. } + | Effect::TurnFaceUp { .. } + | Effect::TurnFaceDown { .. } + | Effect::ExtraTurn { .. } + | Effect::GrantExtraLoyaltyActivations { .. } + | Effect::SkipNextTurn { .. } + | Effect::SkipNextStep { .. } + | Effect::AdditionalPhase { .. } + | Effect::Double { .. } + | Effect::RuntimeHandled { .. } + | Effect::Incubate { .. } + | Effect::Amass { .. } + | Effect::Monstrosity { .. } + | Effect::Specialize + | Effect::Renown { .. } + | Effect::Bolster { .. } + | Effect::Adapt { .. } + | Effect::Learn + | Effect::Forage + | Effect::Harness + | Effect::CollectEvidence { .. } + | Effect::Endure { .. } + | Effect::BlightEffect { .. } + | Effect::Seek { .. } + | Effect::SetLifeTotal { .. } + | Effect::ExchangeLifeWithStat { .. } + | Effect::ExchangeLifeTotals { .. } + | Effect::SetDayNight { .. } + | Effect::GiveControl { .. } + | Effect::RemoveFromCombat { .. } + | Effect::BecomeBlocked { .. } + | Effect::Conjure { .. } + | Effect::ApplyPerpetual { .. } + | Effect::Intensify { .. } + | Effect::DraftFromSpellbook { .. } + | Effect::Unimplemented { .. } => {} + } +} + /// Recursively visit modifications inside an ability's effect graph. /// Descends into `GenericEffect.static_abilities` (the typical carrier of /// continuous modifications emitted from animations), sub-abilities, and @@ -6284,6 +6591,9 @@ fn visit_ability_modifications( for mode_ability in &def.mode_abilities { visit_ability_modifications(mode_ability, visit); } + visit_direct_effect_ability_payloads(&def.effect, |_, payload| { + visit_ability_modifications(payload, visit); + }); } /// Validate every `AddSubtype` modification on the face against the lexicon @@ -6455,6 +6765,13 @@ fn ability_definition_has_unimplemented_parts(def: &AbilityDefinition) -> bool { .mode_abilities .iter() .any(ability_definition_has_unimplemented_parts) + || { + let mut has_unimplemented_parts = false; + visit_direct_effect_ability_payloads(&def.effect, |_, payload| { + has_unimplemented_parts |= ability_definition_has_unimplemented_parts(payload); + }); + has_unimplemented_parts + } } fn additional_cost_has_unimplemented_parts(additional_cost: &AdditionalCost) -> bool { @@ -6503,6 +6820,10 @@ fn collect_ability_missing_parts(def: &AbilityDefinition, missing: &mut Vec bool { return false; } } + let mut supported = true; + visit_direct_effect_ability_payloads(&def.effect, |_, payload| { + supported &= is_ability_supported(payload); + }); + if !supported { + return false; + } true } @@ -7327,6 +7655,9 @@ fn extract_ability_features( for mode_ab in &def.mode_abilities { extract_ability_features(mode_ab, features); } + visit_direct_effect_ability_payloads(&def.effect, |_, payload| { + extract_ability_features(payload, features); + }); } /// Extract QuantityRef variants from within conditions. @@ -7927,48 +8258,12 @@ fn ability_tree_any(def: &AbilityDefinition, pred: &impl Fn(&AbilityDefinition) return true; } } - // Compound effects that embed AbilityDefinitions - match &*def.effect { - Effect::FlipCoin { - win_effect, - lose_effect, - .. - } - | Effect::FlipCoins { - win_effect, - lose_effect, - .. - } => { - if let Some(ref w) = win_effect { - if ability_tree_any(w, pred) { - return true; - } - } - if let Some(ref l) = lose_effect { - if ability_tree_any(l, pred) { - return true; - } - } - } - Effect::FlipCoinUntilLose { win_effect } if ability_tree_any(win_effect, pred) => { - return true; - } - Effect::RollDie { results, .. } => { - for branch in results { - if ability_tree_any(&branch.effect, pred) { - return true; - } - } - } - Effect::ChooseOneOf { branches, .. } - if branches.iter().any(|branch| ability_tree_any(branch, pred)) => - { - return true; - } - Effect::CreateDelayedTrigger { effect, .. } if ability_tree_any(effect, pred) => { - return true; - } - _ => {} + let mut found = false; + visit_direct_effect_ability_payloads(&def.effect, |_, payload| { + found |= ability_tree_any(payload, pred); + }); + if found { + return true; } // ContinuousModification::GrantAbility inside GenericEffect if let Effect::GenericEffect { @@ -8803,11 +9098,9 @@ fn audit_card_lines(oracle_text: &str, face: &CardFace) -> Vec if let Some(else_ab) = &def.else_ability { push_ability_tree(else_ab, out); } - if let Effect::ChooseOneOf { branches, .. } = def.effect.as_ref() { - for branch in branches { - push_ability_tree(branch, out); - } - } + visit_direct_effect_ability_payloads(&def.effect, |_, payload| { + push_ability_tree(payload, out); + }); } for a in face.abilities.iter() { push_ability_tree(a, &mut elements); @@ -11021,8 +11314,10 @@ mod tests { use crate::database::legality::{legalities_to_export_map, LegalityStatus}; use crate::parser::oracle_ir::diagnostic::{CascadeSlot, OracleDiagnostic}; use crate::types::ability::{ - AbilityKind, CounterTransferMode, Effect, PreventionAmount, PreventionScope, - ReplacementCondition, TargetFilter, + AbilityCondition, AbilityKind, ContinuousModification, ControllerRef, CounterTransferMode, + DieResultBranch, Effect, PileSource, PlayerFilter, PlayerScope, PreventionAmount, + PreventionScope, ReplacementCondition, StaticDefinition, TargetFilter, VoteTally, + VoteVisibility, VoterScope, }; use crate::types::card_type::CardType; use crate::types::identifiers::{CardId, ObjectId}; @@ -11937,6 +12232,374 @@ mod tests { } } + fn delayed_trigger_payload(effect: Effect) -> AbilityDefinition { + AbilityDefinition::new( + AbilityKind::Spell, + Effect::CreateDelayedTrigger { + condition: DelayedTriggerCondition::AtNextPhase { phase: Phase::End }, + effect: Box::new(AbilityDefinition::new(AbilityKind::Spell, effect)), + uses_tracked_set: false, + }, + ) + } + + fn direct_effect_payload_matrix() -> Vec { + let payload = |name: &str| { + let mut payload = AbilityDefinition::new( + AbilityKind::Spell, + Effect::unimplemented(name, format!("unsupported {name}")), + ); + payload.condition = Some(AbilityCondition::HasMaxSpeed); + Box::new(payload) + }; + + vec![ + AbilityDefinition::new( + AbilityKind::Spell, + Effect::Vote { + choices: vec!["choice one".into(), "choice two".into()], + per_choice_effect: vec![ + payload("vote_per_choice_one"), + payload("vote_per_choice_two"), + ], + starting_with: ControllerRef::You, + voter_scope: VoterScope::AllPlayers, + tally_mode: VoteTally::PerVote, + subject: VoteSubject::Named, + visibility: VoteVisibility::Open, + }, + ), + AbilityDefinition::new( + AbilityKind::Spell, + Effect::Vote { + choices: vec![], + per_choice_effect: vec![], + starting_with: ControllerRef::You, + voter_scope: VoterScope::AllPlayers, + tally_mode: VoteTally::PerVote, + subject: VoteSubject::Objects { + candidate_filter: TargetFilter::Any, + outcome_template: payload("vote_object_outcome"), + }, + visibility: VoteVisibility::Open, + }, + ), + AbilityDefinition::new( + AbilityKind::Spell, + Effect::SeparateIntoPiles { + partition_subject: VoterScope::EachOpponent, + object_filter: TargetFilter::Any, + chooser: PlayerScope::Controller, + chosen_pile_effect: payload("separate_chosen"), + pile_source: PileSource::Battlefield, + unchosen_pile_effect: Some(payload("separate_unchosen")), + }, + ), + AbilityDefinition::new( + AbilityKind::Spell, + Effect::RevealFromHand { + filter: TargetFilter::Any, + on_decline: Some(payload("reveal_decline")), + }, + ), + AbilityDefinition::new( + AbilityKind::Spell, + Effect::CreateDelayedTrigger { + condition: DelayedTriggerCondition::AtNextPhase { phase: Phase::End }, + effect: payload("delayed_trigger"), + uses_tracked_set: false, + }, + ), + AbilityDefinition::new( + AbilityKind::Spell, + Effect::RollDie { + count: QuantityExpr::Fixed { value: 1 }, + sides: 6, + results: vec![ + DieResultBranch { + min: 1, + max: 1, + effect: payload("roll_die_result_one"), + }, + DieResultBranch { + min: 2, + max: 2, + effect: payload("roll_die_result_two"), + }, + ], + modifier: None, + }, + ), + AbilityDefinition::new( + AbilityKind::Spell, + Effect::FlipCoin { + win_effect: Some(payload("flip_coin_win")), + lose_effect: None, + flipper: TargetFilter::Controller, + }, + ), + AbilityDefinition::new( + AbilityKind::Spell, + Effect::FlipCoin { + win_effect: None, + lose_effect: Some(payload("flip_coin_lose")), + flipper: TargetFilter::Controller, + }, + ), + AbilityDefinition::new( + AbilityKind::Spell, + Effect::FlipCoins { + count: QuantityExpr::Fixed { value: 2 }, + win_effect: Some(payload("flip_coins_win")), + lose_effect: None, + flipper: TargetFilter::Controller, + }, + ), + AbilityDefinition::new( + AbilityKind::Spell, + Effect::FlipCoins { + count: QuantityExpr::Fixed { value: 2 }, + win_effect: None, + lose_effect: Some(payload("flip_coins_lose")), + flipper: TargetFilter::Controller, + }, + ), + AbilityDefinition::new( + AbilityKind::Spell, + Effect::FlipCoinUntilLose { + win_effect: payload("flip_until_lose_win"), + }, + ), + AbilityDefinition::new( + AbilityKind::Spell, + Effect::ChooseOneOf { + chooser: PlayerFilter::Controller, + branches: vec![ + *payload("choose_one_branch_one"), + *payload("choose_one_branch_two"), + ], + }, + ), + ] + } + + #[test] + fn direct_effect_payload_edges_reach_coverage_consumers() { + let mut all_edges = Vec::new(); + for definition in direct_effect_payload_matrix() { + let mut visited = Vec::new(); + visit_direct_effect_ability_payloads(&definition.effect, |actual_edge, payload| { + let Effect::Unimplemented { name, .. } = payload.effect.as_ref() else { + panic!("payload matrix must contain an unimplemented leaf"); + }; + visited.push((actual_edge, name.clone())); + }); + let expected_names: Vec<_> = visited.iter().map(|(_, name)| name.clone()).collect(); + let projected = build_ability_item(&definition); + assert_eq!( + projected + .children + .iter() + .map(|child| child.label.clone()) + .collect::>(), + expected_names, + "the parse-details report must project every direct payload" + ); + assert!(ability_definition_has_unimplemented_parts(&definition)); + assert!(!is_ability_supported(&definition)); + for (_, name) in &visited { + assert!(ability_tree_any(&definition, &|payload| { + matches!(payload.effect.as_ref(), Effect::Unimplemented { name: payload_name, .. } if payload_name == name) + })); + } + + let mut missing = Vec::new(); + collect_ability_missing_parts(&definition, &mut missing); + let expected_gaps: Vec<_> = visited + .iter() + .map(|(_, name)| format!("Effect:{name}")) + .collect(); + assert_eq!(missing, expected_gaps); + + let mut face = make_face(); + face.abilities.push(definition.clone()); + assert_eq!( + card_face_gaps(&face), + expected_gaps, + "the card-face gap report must include every direct payload" + ); + assert!(card_face_has_unimplemented_parts(&face)); + + let mut features = HashMap::new(); + extract_ability_features(&definition, &mut features); + assert!(features.contains_key("condition:HasMaxSpeed")); + all_edges.extend(visited.into_iter().map(|(edge, _)| edge)); + } + assert_eq!( + all_edges, + vec![ + DirectEffectPayloadEdge::VotePerChoice, + DirectEffectPayloadEdge::VotePerChoice, + DirectEffectPayloadEdge::VoteObjectOutcome, + DirectEffectPayloadEdge::SeparateIntoPilesChosen, + DirectEffectPayloadEdge::SeparateIntoPilesUnchosen, + DirectEffectPayloadEdge::RevealFromHandOnDecline, + DirectEffectPayloadEdge::CreateDelayedTriggerEffect, + DirectEffectPayloadEdge::RollDieResult, + DirectEffectPayloadEdge::RollDieResult, + DirectEffectPayloadEdge::FlipCoinWin, + DirectEffectPayloadEdge::FlipCoinLose, + DirectEffectPayloadEdge::FlipCoinsWin, + DirectEffectPayloadEdge::FlipCoinsLose, + DirectEffectPayloadEdge::FlipCoinUntilLoseWin, + DirectEffectPayloadEdge::ChooseOneOfBranch, + DirectEffectPayloadEdge::ChooseOneOfBranch, + ] + ); + } + + #[test] + fn direct_effect_payload_controls_are_empty_or_supported() { + let empty = AbilityDefinition::new( + AbilityKind::Spell, + Effect::FlipCoin { + win_effect: None, + lose_effect: None, + flipper: TargetFilter::Controller, + }, + ); + let mut visited = Vec::new(); + visit_direct_effect_ability_payloads(&empty.effect, |edge, _| visited.push(edge)); + assert!(visited.is_empty()); + assert!(build_ability_item(&empty).children.is_empty()); + assert!(!ability_definition_has_unimplemented_parts(&empty)); + assert!(is_ability_supported(&empty)); + + let supported = AbilityDefinition::new( + AbilityKind::Spell, + Effect::ChooseOneOf { + chooser: PlayerFilter::Controller, + branches: vec![AbilityDefinition::new( + AbilityKind::Spell, + Effect::Draw { + count: QuantityExpr::Fixed { value: 1 }, + target: TargetFilter::Controller, + }, + )], + }, + ); + assert!(is_ability_supported(&supported)); + assert!(!ability_definition_has_unimplemented_parts(&supported)); + let mut missing = Vec::new(); + collect_ability_missing_parts(&supported, &mut missing); + assert!(missing.is_empty()); + } + + #[test] + fn direct_effect_payloads_reach_modification_visitors() { + let definition = AbilityDefinition::new( + AbilityKind::Spell, + Effect::RevealFromHand { + filter: TargetFilter::Any, + on_decline: Some(Box::new(AbilityDefinition::new( + AbilityKind::Spell, + Effect::GenericEffect { + static_abilities: vec![StaticDefinition::continuous().modifications(vec![ + ContinuousModification::AddSubtype { + subtype: "Wizard".into(), + }, + ])], + duration: None, + target: None, + end_cost: None, + }, + ))), + }, + ); + let mut subtypes = Vec::new(); + visit_ability_modifications(&definition, &mut |modification| { + if let ContinuousModification::AddSubtype { subtype } = modification { + subtypes.push(subtype.clone()); + } + }); + assert_eq!(subtypes, vec!["Wizard"]); + } + + #[test] + fn delayed_trigger_payload_projects_and_reports_unimplemented_parts() { + let unsupported = delayed_trigger_payload(Effect::unimplemented( + "delayed_payload", + "unsupported delayed effect", + )); + let projected = build_ability_item(&unsupported); + assert_eq!( + projected.children.len(), + 1, + "a delayed trigger's executable payload must appear in its parse signature" + ); + assert_eq!(projected.children[0].label, "delayed_payload"); + + let mut unsupported_face = make_face(); + unsupported_face.abilities.push(unsupported); + assert!( + card_face_gaps(&unsupported_face) + .iter() + .any(|gap| gap == "Effect:delayed_payload"), + "an unimplemented delayed payload must be reported as a card-face gap" + ); + assert!( + card_face_has_unimplemented_parts(&unsupported_face), + "an unimplemented delayed payload must make the card face unsupported" + ); + + let mut supported_face = make_face(); + supported_face + .abilities + .push(delayed_trigger_payload(Effect::Draw { + count: QuantityExpr::Fixed { value: 1 }, + target: TargetFilter::Controller, + })); + assert!( + card_face_gaps(&supported_face).is_empty(), + "a supported delayed payload must not create a card-face gap" + ); + assert!( + !card_face_has_unimplemented_parts(&supported_face), + "a supported delayed payload must keep the card face supported" + ); + } + + #[test] + fn replacement_execute_projects_delayed_trigger_payload_support() { + let replacement_supported = |payload| { + let mut face = make_face(); + face.replacements.push( + ReplacementDefinition::new(ReplacementEvent::Draw) + .execute(delayed_trigger_payload(payload)), + ); + build_parse_details_for_face(&face) + .into_iter() + .find(|item| item.category == ParseCategory::Replacement) + .expect("replacement must be projected") + .supported + }; + + assert!( + !replacement_supported(Effect::unimplemented( + "delayed_replacement_payload", + "unsupported replacement payload", + )), + "an unimplemented delayed payload must make replacement execution unsupported" + ); + assert!( + replacement_supported(Effect::Draw { + count: QuantityExpr::Fixed { value: 1 }, + target: TargetFilter::Controller, + }), + "a supported delayed payload must keep replacement execution supported" + ); + } + #[test] fn card_face_with_nested_mode_unimplemented_is_detected() { let mut face = make_face(); diff --git a/crates/engine/src/game/day_night.rs b/crates/engine/src/game/day_night.rs index 985ab5d63b..e03a649772 100644 --- a/crates/engine/src/game/day_night.rs +++ b/crates/engine/src/game/day_night.rs @@ -178,6 +178,7 @@ mod tests { casting_restrictions: vec![], casting_options: vec![], layout_kind: None, + parse_warnings: vec![], }); id } diff --git a/crates/engine/src/game/effects/become_copy.rs b/crates/engine/src/game/effects/become_copy.rs index d6448b5634..8d2a44385b 100644 --- a/crates/engine/src/game/effects/become_copy.rs +++ b/crates/engine/src/game/effects/become_copy.rs @@ -1167,6 +1167,7 @@ mod tests { casting_restrictions: vec![], casting_options: vec![], layout_kind: None, + parse_warnings: vec![], }); } diff --git a/crates/engine/src/game/effects/change_zone.rs b/crates/engine/src/game/effects/change_zone.rs index 31143ec135..081ad1e1ef 100644 --- a/crates/engine/src/game/effects/change_zone.rs +++ b/crates/engine/src/game/effects/change_zone.rs @@ -9559,6 +9559,7 @@ mod tests { casting_restrictions: vec![], casting_options: vec![], layout_kind: None, + parse_warnings: vec![], }); } diff --git a/crates/engine/src/game/effects/flip_coin.rs b/crates/engine/src/game/effects/flip_coin.rs index 45a9badb58..e20ccac43d 100644 --- a/crates/engine/src/game/effects/flip_coin.rs +++ b/crates/engine/src/game/effects/flip_coin.rs @@ -934,6 +934,7 @@ mod tests { casting_restrictions: vec![], casting_options: vec![], layout_kind: None, + parse_warnings: vec![], }); id } diff --git a/crates/engine/src/game/effects/flip_permanent.rs b/crates/engine/src/game/effects/flip_permanent.rs index 11241f497a..1c3ecf3270 100644 --- a/crates/engine/src/game/effects/flip_permanent.rs +++ b/crates/engine/src/game/effects/flip_permanent.rs @@ -148,6 +148,7 @@ mod tests { casting_restrictions: vec![], casting_options: vec![], layout_kind: Some(crate::types::card::LayoutKind::Flip), + parse_warnings: vec![], }); id } diff --git a/crates/engine/src/game/effects/mana.rs b/crates/engine/src/game/effects/mana.rs index ad9f8309c0..ed7bc934ea 100644 --- a/crates/engine/src/game/effects/mana.rs +++ b/crates/engine/src/game/effects/mana.rs @@ -198,7 +198,7 @@ pub fn resolve( &ability.effect, state, ability.source_id, - Some(ability), + Some(count_ability), ) }) .flatten(); diff --git a/crates/engine/src/game/effects/prepare.rs b/crates/engine/src/game/effects/prepare.rs index 2f720163d1..cb17a3aa3a 100644 --- a/crates/engine/src/game/effects/prepare.rs +++ b/crates/engine/src/game/effects/prepare.rs @@ -1260,6 +1260,7 @@ mod tests { casting_restrictions: Vec::new(), casting_options: Vec::new(), layout_kind: Some(LayoutKind::Prepare), + parse_warnings: vec![], } } } diff --git a/crates/engine/src/game/effects/set_room_door_lock.rs b/crates/engine/src/game/effects/set_room_door_lock.rs index debbc1d22a..fe6728e5ad 100644 --- a/crates/engine/src/game/effects/set_room_door_lock.rs +++ b/crates/engine/src/game/effects/set_room_door_lock.rs @@ -139,6 +139,7 @@ mod tests { casting_restrictions: Vec::new(), casting_options: Vec::new(), layout_kind: Some(crate::types::card::LayoutKind::Split), + parse_warnings: vec![], } } diff --git a/crates/engine/src/game/effects/token.rs b/crates/engine/src/game/effects/token.rs index 7fb6e53adb..bd691211e0 100644 --- a/crates/engine/src/game/effects/token.rs +++ b/crates/engine/src/game/effects/token.rs @@ -3295,6 +3295,9 @@ fn incubator_phyrexian_back_face() -> BackFaceData { strive_cost: None, casting_restrictions: vec![], casting_options: vec![], + // Built in code from CR 111.10i, not parsed from printed text, so there is + // no parse to have gone wrong. + parse_warnings: vec![], layout_kind: None, } } diff --git a/crates/engine/src/game/effects/transform_effect.rs b/crates/engine/src/game/effects/transform_effect.rs index 6a5572cacf..efb2804a0c 100644 --- a/crates/engine/src/game/effects/transform_effect.rs +++ b/crates/engine/src/game/effects/transform_effect.rs @@ -222,6 +222,7 @@ mod tests { // face so `is_double_faced_permanent` recognizes it (the mass-transform // resolver pre-filters on that authority). layout_kind: Some(crate::types::card::LayoutKind::Transform), + parse_warnings: vec![], }); id } diff --git a/crates/engine/src/game/engine.rs b/crates/engine/src/game/engine.rs index b30586e2bf..598b66d572 100644 --- a/crates/engine/src/game/engine.rs +++ b/crates/engine/src/game/engine.rs @@ -14230,6 +14230,7 @@ mod priority_principal_tests { casting_restrictions: Vec::new(), casting_options: Vec::new(), layout_kind: None, + parse_warnings: vec![], }); for _ in 0..mana_count { runner.state_mut().players[0].mana_pool.add(ManaUnit::new( diff --git a/crates/engine/src/game/engine_debug.rs b/crates/engine/src/game/engine_debug.rs index db293d883c..a44e187059 100644 --- a/crates/engine/src/game/engine_debug.rs +++ b/crates/engine/src/game/engine_debug.rs @@ -1551,6 +1551,7 @@ mod tests { casting_restrictions: Vec::new(), casting_options: Vec::new(), layout_kind: Some(LayoutKind::Prepare), + parse_warnings: vec![], } } diff --git a/crates/engine/src/game/engine_mdfc_land_tests.rs b/crates/engine/src/game/engine_mdfc_land_tests.rs index b2637d7df5..c82568085c 100644 --- a/crates/engine/src/game/engine_mdfc_land_tests.rs +++ b/crates/engine/src/game/engine_mdfc_land_tests.rs @@ -62,6 +62,7 @@ fn make_back_face( casting_restrictions: Vec::new(), casting_options: Vec::new(), layout_kind, + parse_warnings: vec![], } } diff --git a/crates/engine/src/game/engine_resolution_choices.rs b/crates/engine/src/game/engine_resolution_choices.rs index fa08fddddc..eb9c5e96ec 100644 --- a/crates/engine/src/game/engine_resolution_choices.rs +++ b/crates/engine/src/game/engine_resolution_choices.rs @@ -3961,6 +3961,7 @@ pub(super) fn handle_resolution_choice( primary_enter_tapped, rest_destination, }; + state.waiting_for = WaitingFor::Priority { player }; let events_before_partition = events.len(); match apply_search_partition( state, @@ -10225,8 +10226,8 @@ mod tests { /// than a stricter sentence. /// /// POPULATION IS DISCOVERED, NOT LISTED. The guard walks the crate and enforces on every file - /// carrying the opt-in marker comment, so a seventh file joining the class is covered the - /// moment it opts in, and a hardcoded list cannot drift out of sync with the class it names. + /// carrying the opt-in marker comment, so a newly enrolling file is covered the moment it opts + /// in, and a hardcoded list cannot drift out of sync with the class it names. /// A whole-crate sweep was measured and rejected as out of scope, not as unnecessary: /// `crates/engine/src` carries 216 such anchors across 61 files (`game/engine.rs` alone 29), /// ~20x this change. Regenerate that census with: @@ -10253,7 +10254,7 @@ mod tests { const TEST_MOD: &str = "#[cfg(test)]\nmod tests {"; // Enrolment floor. Not a list — a non-vacuity guard, so a broken walk or a renamed marker // reds instead of passing on an empty population. Raise it when a file joins. - const ENROLLED_FLOOR: usize = 6; + const ENROLLED_FLOOR: usize = 8; // `CR 732.2a` has no colon; `std::vec` no digit; `field:1` and `{"Life":1}` have a name or // a quote before the colon. A bare back-reference is recognized only after whitespace, a diff --git a/crates/engine/src/game/engine_tests.rs b/crates/engine/src/game/engine_tests.rs index ab045257ab..c665caea18 100644 --- a/crates/engine/src/game/engine_tests.rs +++ b/crates/engine/src/game/engine_tests.rs @@ -1656,6 +1656,7 @@ fn room_back_face(name: &str) -> BackFaceData { casting_restrictions: Vec::new(), casting_options: Vec::new(), layout_kind: Some(crate::types::card::LayoutKind::Split), + parse_warnings: vec![], } } diff --git a/crates/engine/src/game/flip.rs b/crates/engine/src/game/flip.rs index ff53e9eea8..e5db14ecc4 100644 --- a/crates/engine/src/game/flip.rs +++ b/crates/engine/src/game/flip.rs @@ -456,6 +456,7 @@ mod tests { casting_restrictions: vec![], casting_options: vec![], layout_kind: Some(crate::types::card::LayoutKind::Flip), + parse_warnings: vec![], }); id diff --git a/crates/engine/src/game/game_object.rs b/crates/engine/src/game/game_object.rs index 2e87a31108..d95807ec68 100644 --- a/crates/engine/src/game/game_object.rs +++ b/crates/engine/src/game/game_object.rs @@ -239,6 +239,25 @@ pub struct BackFaceData { pub strive_cost: Option, pub casting_restrictions: Vec, pub casting_options: Vec, + /// Parser diagnostics for THIS face — the `BackFaceData` half of + /// [`GameObject::parse_warnings`], and per-face for the same reason `abilities` + /// is: the two faces are parsed independently, so a card whose front reads + /// cleanly and whose back does not is the normal case rather than a corner one. + /// + /// Without this field the diagnostic was not a per-face fact at all. Face + /// application copies field by field, so a transform kept the FRONT face's + /// diagnostics on the object while displaying the back face's rules text: a + /// front-clean card looked clean after transforming into a back face the parser + /// could not fully read, and a front-dirty one kept a diagnostic that no longer + /// described anything. `ai_support::shortcut_efficacy` reads the object field as + /// evidence that the printed rules text is fully modelled, so the stale answer + /// was load-bearing in both directions. + /// + /// `serde(default)` keeps every persisted dump loadable — an older + /// `BackFaceData` simply carries no diagnostics — and `skip_serializing_if` + /// keeps a clean parse byte-identical on the way out. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub parse_warnings: Vec, /// Source layout kind — distinguishes Modal DFCs from Transform DFCs /// so the engine can offer face-choice for MDFCs (CR 712.12). #[serde(default, skip_serializing_if = "Option::is_none")] @@ -549,6 +568,38 @@ pub struct GameObject { #[serde(default, skip_serializing_if = "Vec::is_empty")] pub spellbook: Vec, + /// Parser diagnostics for the DISPLAYED face, copied verbatim from + /// `CardFace::parse_warnings` by `game::printed_cards`. A transform swaps this + /// along with the rest of the face, through [`BackFaceData::parse_warnings`] — + /// the two faces parse independently, so a card can be clean on one and not the + /// other, and reporting the wrong face's diagnostics is worse than reporting + /// none. + /// + /// NOT a rules field, and it does not change how anything resolves. It is + /// carried onto the object because a diagnostic is EVIDENCE ABOUT the rules + /// content: it records that the parser saw printed text it could not turn + /// into an `AbilityDefinition`. `game::coverage` already reads the same list + /// off the face to decide whether a card is supported. Any consumer that + /// wants to prove an object's printed rules text is fully modelled has to be + /// able to see that the parse was lossy, and before this field existed that + /// evidence stopped at the card database. + /// + /// `skip_serializing_if` keeps every existing dump byte-identical: the field + /// is empty for a clean parse, which is the overwhelming majority of objects. + /// + /// DELIBERATELY ABSENT FROM `CopiableValues`. CR 707.2 gives a copy the + /// copiable values of the original's CHARACTERISTICS, and CR 707.2a says why + /// the abilities come along: "those values are derived from its rules text". + /// A parse diagnostic is not derived from the rules text — it is a statement + /// about this engine's READING of it — so it is not a characteristic and a + /// copy does not acquire it. The consequence, stated rather than left to be + /// discovered: a token copy carries the source's `abilities` but its own + /// (empty) diagnostics. That is exactly the coverage a copy had before this + /// field existed; the field narrows the gap for printed objects and leaves + /// the copy case where it already was. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub parse_warnings: Vec, + // Back face data for double-faced cards (DFCs) pub back_face: Option, @@ -1270,6 +1321,17 @@ fn _gameobject_partition_is_total(o: &GameObject) { token_image_ref: _, source_related_token_ids: _, spellbook: _, + // OMITTED, SAFE BY WRITE SITE. Every write is a FACE INSTALL: + // `printed_cards::apply_card_face_to_object` (front) and + // `apply_back_face_to_object` (the face a transform swaps in), each a verbatim + // clone of that face's own diagnostics, plus the two `game::visibility` + // redactions which act on a projected copy and never on stored state. So the + // field is a function of WHICH FACE IS DISPLAYED, and `transformed` — compared + // above — is that same function's discriminator: two states this comparator + // calls equal are showing the same face of the same card, and therefore agree + // here. Nothing accumulates in it, so it cannot become the per-iteration drift + // the §5.2c ADD set exists to catch. + parse_warnings: _, back_face: _, specialize_faces: _, specialized_color: _, @@ -2144,6 +2206,7 @@ impl GameObject { token_image_ref: None, source_related_token_ids: Vec::new(), spellbook: Vec::new(), + parse_warnings: Vec::new(), back_face: None, specialize_faces: None, specialized_color: None, diff --git a/crates/engine/src/game/mana_abilities.rs b/crates/engine/src/game/mana_abilities.rs index f7f593aee6..d926a781e7 100644 --- a/crates/engine/src/game/mana_abilities.rs +++ b/crates/engine/src/game/mana_abilities.rs @@ -943,7 +943,13 @@ pub(crate) fn mana_choice_prompt( }; match produced { ManaProduction::AnyOneColor { color_options, .. } if color_options.len() > 1 => { - Some(ManaChoicePrompt::SingleColor { + let produces_mana = ability + .map(|ability| { + !super::effects::mana::resolve_mana_types_for_ability(produced, state, ability) + .is_empty() + }) + .unwrap_or(true); + produces_mana.then(|| ManaChoicePrompt::SingleColor { options: color_options.iter().map(mana_color_to_type).collect(), }) } @@ -978,7 +984,13 @@ pub(crate) fn mana_choice_prompt( .into_iter() .map(|color| mana_color_to_type(&color)) .collect::>(); - if options.len() > 1 { + let produces_mana = ability + .map(|ability| { + !super::effects::mana::resolve_mana_types_for_ability(produced, state, ability) + .is_empty() + }) + .unwrap_or(true); + if options.len() > 1 && produces_mana { Some(ManaChoicePrompt::SingleColor { options }) } else { None @@ -1038,7 +1050,13 @@ pub(crate) fn mana_choice_prompt( owner, source_id, ); - if options.len() > 1 { + let produces_mana = ability + .map(|ability| { + !super::effects::mana::resolve_mana_types_for_ability(produced, state, ability) + .is_empty() + }) + .unwrap_or(true); + if options.len() > 1 && produces_mana { Some(ManaChoicePrompt::SingleColor { options }) } else { None @@ -1051,7 +1069,13 @@ pub(crate) fn mana_choice_prompt( ManaProduction::AnyInCommandersColorIdentity { .. } => { let owner = state.objects.get(&source_id).map(|obj| obj.controller)?; let identity = super::commander::commander_color_identity(state, owner); - if identity.len() > 1 { + let produces_mana = ability + .map(|ability| { + !super::effects::mana::resolve_mana_types_for_ability(produced, state, ability) + .is_empty() + }) + .unwrap_or(true); + if identity.len() > 1 && produces_mana { Some(ManaChoicePrompt::SingleColor { options: identity.iter().map(mana_color_to_type).collect(), }) @@ -1067,7 +1091,13 @@ pub(crate) fn mana_choice_prompt( ManaProduction::OpponentLandColors { .. } => { let owner = state.objects.get(&source_id).map(|obj| obj.controller)?; let options = super::mana_sources::opponent_land_color_options(state, owner); - if options.len() > 1 { + let produces_mana = ability + .map(|ability| { + !super::effects::mana::resolve_mana_types_for_ability(produced, state, ability) + .is_empty() + }) + .unwrap_or(true); + if options.len() > 1 && produces_mana { Some(ManaChoicePrompt::SingleColor { options }) } else { None diff --git a/crates/engine/src/game/printed_cards.rs b/crates/engine/src/game/printed_cards.rs index b156e6c9c0..de0021ad5f 100644 --- a/crates/engine/src/game/printed_cards.rs +++ b/crates/engine/src/game/printed_cards.rs @@ -184,6 +184,10 @@ pub fn apply_card_face_to_object(obj: &mut GameObject, card_face: &CardFace) { obj.base_printed_ref = obj.printed_ref.clone(); obj.source_related_token_ids = card_face.metadata.related_token_ids.clone(); obj.spellbook = card_face.metadata.spellbook.clone(); + // Evidence that this face's printed text did not parse cleanly. Carried onto + // the object so a consumer can tell "this card has no such ability" apart from + // "the parser could not read that clause". + obj.parse_warnings = card_face.parse_warnings.clone(); obj.modal = card_face.modal.clone(); obj.additional_cost = card_face.additional_cost.clone(); obj.strive_cost = card_face.strive_cost.clone(); @@ -292,6 +296,9 @@ pub fn apply_card_face_to_back_face(back_face: &mut BackFaceData, card_face: &Ca back_face.strive_cost = card_face.strive_cost.clone(); back_face.casting_restrictions = card_face.casting_restrictions.clone(); back_face.casting_options = card_face.casting_options.clone(); + // Same copy, same reason, as `apply_card_face_to_object`: evidence that THIS + // face's printed text did not parse cleanly travels with the face. + back_face.parse_warnings = card_face.parse_warnings.clone(); } pub fn apply_back_face_to_object(obj: &mut GameObject, back_face: BackFaceData) { @@ -341,6 +348,10 @@ pub fn apply_back_face_to_object(obj: &mut GameObject, back_face: BackFaceData) obj.strive_cost = back_face.strive_cost; obj.casting_restrictions = back_face.casting_restrictions; obj.casting_options = back_face.casting_options; + // The displayed face's diagnostics replace the outgoing face's. Both + // directions matter and both are this one line: a back face the parser could + // not fully read starts gating here, and transforming back off it stops. + obj.parse_warnings = back_face.parse_warnings; } /// CR 306.5b + CR 310.4b + CR 614.1c: Seed the intrinsic "enters with N @@ -743,6 +754,9 @@ pub fn snapshot_object_face(obj: &GameObject) -> BackFaceData { strive_cost: obj.strive_cost.clone(), casting_restrictions: obj.casting_restrictions.clone(), casting_options: obj.casting_options.clone(), + // The outgoing face's diagnostics ride out with it, so the return trip + // restores them rather than inheriting whatever the other face had. + parse_warnings: obj.parse_warnings.clone(), layout_kind: None, } } @@ -788,6 +802,10 @@ pub fn snapshot_object_base_face(obj: &GameObject) -> BackFaceData { strive_cost: obj.strive_cost.clone(), casting_restrictions: obj.casting_restrictions.clone(), casting_options: obj.casting_options.clone(), + // Face-derived, with no base/live split to choose between: nothing writes + // `parse_warnings` except a face install, so the live field IS the printed + // face's diagnostics and the layer system never touches it. + parse_warnings: obj.parse_warnings.clone(), layout_kind: None, } } @@ -1040,6 +1058,8 @@ fn back_face_for_card_face_with_printed_ref( strive_cost: None, casting_restrictions: Vec::new(), casting_options: Vec::new(), + // Empty seed; `apply_card_face_to_back_face` below fills it from the face. + parse_warnings: Vec::new(), layout_kind: None, }; apply_card_face_to_back_face(&mut back, face); @@ -1902,6 +1922,7 @@ mod tests { casting_restrictions: vec![], casting_options: vec![], layout_kind: None, + parse_warnings: vec![], }); rehydrate_game_from_card_db(&mut state, &db); diff --git a/crates/engine/src/game/replacement.rs b/crates/engine/src/game/replacement.rs index 1ef4053226..ff08ad6c75 100644 --- a/crates/engine/src/game/replacement.rs +++ b/crates/engine/src/game/replacement.rs @@ -1003,10 +1003,14 @@ pub fn replacement_choice_waiting_for(player: PlayerId, state: &GameState) -> Wa } /// CR 614.12a: Park on the replacement choice for `player`, unless a downstream -/// effect (a Devour as-enters Sacrifice `EffectZoneChoice`) already surfaced its -/// own interactive prompt — then leave it so the pending choice isn't clobbered. +/// as-enters effect already surfaced its own interactive prompt. Leave that prompt +/// in place so the entry choice completes before the surrounding ability resumes. pub fn park_waiting_for(state: &mut GameState, player: PlayerId) { - if matches!(state.waiting_for, WaitingFor::EffectZoneChoice { .. }) { + if matches!( + state.waiting_for, + WaitingFor::CopyTargetChoice { .. } | WaitingFor::ReturnAsAuraTarget { .. } + ) || super::engine_resolution_choices::handles(&state.waiting_for) + { return; } state.waiting_for = replacement_choice_waiting_for(player, state); diff --git a/crates/engine/src/game/specialize.rs b/crates/engine/src/game/specialize.rs index 95d1332ff0..67263dcac8 100644 --- a/crates/engine/src/game/specialize.rs +++ b/crates/engine/src/game/specialize.rs @@ -73,7 +73,10 @@ pub fn eligible_specialize_colors( .collect() } -fn empty_back_face() -> BackFaceData { +/// An all-empty `BackFaceData`. `pub(crate)` rather than private because it is the only +/// constructor of the type that does not hand-roll the full field list, and a second copy +/// of that literal would go stale the moment `BackFaceData` gains a field. +pub(crate) fn empty_back_face() -> BackFaceData { BackFaceData { name: String::new(), power: None, @@ -95,6 +98,7 @@ fn empty_back_face() -> BackFaceData { strive_cost: None, casting_restrictions: vec![], casting_options: vec![], + parse_warnings: vec![], layout_kind: None, } } diff --git a/crates/engine/src/game/stack.rs b/crates/engine/src/game/stack.rs index d18ce4d81e..8ce1bd9436 100644 --- a/crates/engine/src/game/stack.rs +++ b/crates/engine/src/game/stack.rs @@ -4806,6 +4806,7 @@ mod tests { casting_restrictions: vec![], casting_options: vec![], layout_kind: None, + parse_warnings: vec![], } } @@ -9794,7 +9795,7 @@ mod tests { /// /// Self-skips when `/tmp/gamestate.json` is absent (CI lacks the repro). /// `#[ignore]` by default: depends on a local-only 27MB snapshot. Run with - /// `cargo test -p engine -- --ignored real_scute_board`. + /// `cargo test -p phase-engine -- --ignored real_scute_board`. /// Re-parse every `StaticCondition::Unrecognized` carried in a snapshot's /// static definitions through the live `parse_inner_condition`, replacing /// any that now parse to a typed condition. The snapshot's stored text has diff --git a/crates/engine/src/game/token_presets.rs b/crates/engine/src/game/token_presets.rs index d585bda2a6..d150021fc9 100644 --- a/crates/engine/src/game/token_presets.rs +++ b/crates/engine/src/game/token_presets.rs @@ -558,7 +558,7 @@ mod tests { (state, source) } - /// Forces `LazyLock` evaluation in `cargo test -p engine` so an unknown + /// Forces `LazyLock` evaluation in `cargo test -p phase-engine` so an unknown /// `Keyword`/`CoreType`/`ManaColor` variant or a duplicate id panics in /// CI rather than at first production access. (Malformed TOML fails /// earlier, in `build.rs`; the structural conversion there cannot catch diff --git a/crates/engine/src/game/transform.rs b/crates/engine/src/game/transform.rs index 71c6a4b43c..4ef44b7893 100644 --- a/crates/engine/src/game/transform.rs +++ b/crates/engine/src/game/transform.rs @@ -255,6 +255,7 @@ mod tests { use super::*; use crate::game::game_object::BackFaceData; use crate::game::zones::create_object; + use crate::parser::oracle_ir::diagnostic::OracleDiagnostic; use crate::types::card_type::{CardType, CoreType}; use crate::types::identifiers::CardId; use crate::types::keywords::Keyword; @@ -294,6 +295,11 @@ mod tests { obj.base_abilities = Arc::clone(&obj.abilities); obj.color = vec![ManaColor::Green]; obj.base_color = vec![ManaColor::Green]; + obj.parse_warnings = vec![OracleDiagnostic::IgnoredRemainder { + text: "front diagnostic".to_string(), + parser: "transform_test".to_string(), + line_index: 0, + }]; obj.back_face = Some(BackFaceData { name: "Werewolf Back".to_string(), @@ -327,6 +333,11 @@ mod tests { casting_restrictions: vec![], casting_options: vec![], layout_kind: None, + parse_warnings: vec![OracleDiagnostic::IgnoredRemainder { + text: "back diagnostic".to_string(), + parser: "transform_test".to_string(), + line_index: 0, + }], }); id @@ -351,6 +362,10 @@ mod tests { "BackAbility" ); assert_eq!(obj.color, vec![ManaColor::Green, ManaColor::Red]); + assert!(matches!( + obj.parse_warnings.as_slice(), + [OracleDiagnostic::IgnoredRemainder { text, .. }] if text == "back diagnostic" + )); assert!(state.layers_dirty.is_dirty()); assert_eq!(events.len(), 1); assert_eq!(events[0], GameEvent::Transformed { object_id: id }); @@ -368,6 +383,10 @@ mod tests { let obj = &state.objects[&id]; assert!(!obj.transformed); assert_eq!(obj.name, "Werewolf Front"); + assert!(matches!( + obj.parse_warnings.as_slice(), + [OracleDiagnostic::IgnoredRemainder { text, .. }] if text == "front diagnostic" + )); assert_eq!(events.len(), 2); } diff --git a/crates/engine/src/game/triggers.rs b/crates/engine/src/game/triggers.rs index 02df951016..9d4975afda 100644 --- a/crates/engine/src/game/triggers.rs +++ b/crates/engine/src/game/triggers.rs @@ -24719,6 +24719,7 @@ pub mod tests { casting_restrictions: vec![], casting_options: vec![], layout_kind: None, + parse_warnings: vec![], }); } @@ -37537,7 +37538,7 @@ pub mod tests { /// Requires `/tmp/gamestate.json` to be present (the captured snapshot /// is not committed). Skips cleanly when absent. #[test] - #[ignore = "perf benchmark; run with `cargo test -p engine scute_swarm_throughput -- --ignored --nocapture`"] + #[ignore = "perf benchmark; run with `cargo test -p phase-engine scute_swarm_throughput -- --ignored --nocapture`"] fn scute_swarm_throughput() { let path = "/tmp/gamestate.json"; let raw = match std::fs::read_to_string(path) { diff --git a/crates/engine/src/game/visibility.rs b/crates/engine/src/game/visibility.rs index 3b371f680e..d4cbf49626 100644 --- a/crates/engine/src/game/visibility.rs +++ b/crates/engine/src/game/visibility.rs @@ -1807,6 +1807,14 @@ fn hide_card(state: &mut GameState, obj_id: ObjectId) { obj.back_face = None; obj.token_image_ref = None; obj.source_related_token_ids.clear(); + // CR 400.2: library and hand are hidden zones — a viewer without + // look-permission may not see the card's face. `parse_warnings` is + // parser evidence derived from that face's printed text (a + // `SwallowedClause` carries the rules text verbatim), and only 891 of + // 35,657 card faces carry a non-empty vector, so its presence and + // contents both fingerprint the card. Redact it with the rest of the + // printed identity. + obj.parse_warnings.clear(); obj.foretold = false; } } @@ -1836,6 +1844,13 @@ fn redact_face_down_identity_from_observer(obj: &mut crate::game::game_object::G obj.printed_ref = None; obj.base_printed_ref = None; obj.back_face = None; + // CR 708.5 + CR 708.2: a face-down permanent has no name and no abilities, + // and no player but its controller may look at the card underneath. + // `parse_warnings` survives the face-down transformation on the + // authoritative object (measured: a manifested card keeps the printed + // face's warnings), so it must be redacted here for the same reason + // `back_face` is — it is evidence about the hidden printed text. + obj.parse_warnings.clear(); } /// CR 603.3b + CR 400.2: A pending trigger awaiting its @@ -2751,6 +2766,116 @@ mod tests { assert!(hidden.back_face.is_none()); } + /// CR 400.2: a hand is a hidden zone. `parse_warnings` is derived from the + /// hidden face's printed text — an `IgnoredRemainder`/`SwallowedClause` + /// payload quotes that text verbatim, and `skip_serializing_if` makes the + /// field's mere presence a fingerprint (891 of 35,657 faces carry one). + /// Matched pair: the owner keeps the diagnostic, the opponent gets nothing. + /// The owner arm is the reach-guard — without it an empty-opponent + /// assertion would pass even if the field were never populated. + #[test] + fn hidden_hand_card_redacts_parse_warnings_from_opponent() { + let mut state = GameState::new_two_player(42); + let card_id = create_object( + &mut state, + CardId(2), + PlayerId(1), + "Warned Card".to_string(), + Zone::Hand, + ); + state.objects.get_mut(&card_id).unwrap().parse_warnings = vec![ + crate::parser::oracle_ir::diagnostic::OracleDiagnostic::IgnoredRemainder { + text: "and each opponent loses 2 life".to_string(), + parser: "effect_chain".to_string(), + line_index: 0, + }, + ]; + + let owner_view = filter_state_for_viewer(&state, PlayerId(1)); + let owned = owner_view.objects.get(&card_id).unwrap(); + assert_eq!(owned.name, "Warned Card"); + assert_eq!( + owned.parse_warnings.len(), + 1, + "reach guard: the owner must still see the diagnostic, otherwise \ + the opponent assertion below is vacuous" + ); + + let opponent_view = filter_state_for_viewer(&state, PlayerId(0)); + let hidden = opponent_view.objects.get(&card_id).unwrap(); + assert_eq!(hidden.name, "Hidden Card"); + assert!( + hidden.parse_warnings.is_empty(), + "opponent must not receive parse diagnostics for a hidden-zone card" + ); + // The wire payload is the actual leak vector: assert on the serialized + // bytes, not just the in-memory field. + assert!( + !serde_json::to_string(hidden) + .unwrap() + .contains("each opponent loses 2 life"), + "hidden card's serialized payload must not quote its printed text" + ); + } + + /// CR 708.5 + CR 708.2: a face-down permanent has no name and no abilities, + /// and only its controller may look at the card underneath. `manifest` does + /// not reset `parse_warnings`, so the printed face's diagnostics survive on + /// the authoritative object and must be redacted for every other viewer — + /// the same reason `back_face` is redacted on this path. + #[test] + fn face_down_permanent_redacts_parse_warnings_from_observer() { + let mut state = GameState::new(FormatConfig::standard(), 2, 42); + let controller = PlayerId(0); + let secret = create_object( + &mut state, + CardId(7), + controller, + "Secret Manifest".to_string(), + Zone::Library, + ); + { + let obj = state.objects.get_mut(&secret).unwrap(); + obj.card_types = CardType { + supertypes: vec![], + core_types: vec![CoreType::Creature], + subtypes: vec![], + }; + obj.parse_warnings = vec![ + crate::parser::oracle_ir::diagnostic::OracleDiagnostic::IgnoredRemainder { + text: "and each opponent loses 2 life".to_string(), + parser: "effect_chain".to_string(), + line_index: 0, + }, + ]; + } + + let mut events = Vec::new(); + manifest(&mut state, controller, &mut events).unwrap(); + // Reach guard: the field genuinely survives the face-down transform, so + // the observer assertion below is not vacuous. + assert_eq!( + state.objects[&secret].parse_warnings.len(), + 1, + "manifest must leave the printed face's diagnostics on the object" + ); + + let controller_view = filter_state_for_viewer(&state, controller); + assert_eq!( + controller_view.objects[&secret].parse_warnings.len(), + 1, + "the controller may look at their own face-down permanent" + ); + + let observer_view = filter_state_for_viewer(&state, PlayerId(1)); + let observed = observer_view.objects.get(&secret).unwrap(); + assert_eq!(observed.name, "Hidden Card"); + assert!( + observed.parse_warnings.is_empty(), + "observer must not receive parse diagnostics for a face-down permanent" + ); + } + #[test] fn search_choice_is_visible_to_turn_controller() { let mut state = GameState::new_two_player(42); diff --git a/crates/engine/src/game/zone_pipeline.rs b/crates/engine/src/game/zone_pipeline.rs index 88e578cc00..71a33bde4b 100644 --- a/crates/engine/src/game/zone_pipeline.rs +++ b/crates/engine/src/game/zone_pipeline.rs @@ -861,7 +861,7 @@ pub(crate) fn move_object_with_terminal( ReplacementResult::NeedsChoice(player) => { // CR 616.1: park at the single unparked origin (mirrors // `execute_zone_move`'s NeedsChoice arm) so the prompt surfaces. - replacement::park_waiting_for(state, player); + state.waiting_for = replacement::replacement_choice_waiting_for(player, state); // CR 701.24a: stash the requested library placement on the // parked record so the resume path // (`engine_replacement::handle_replacement_choice`) threads it @@ -938,7 +938,7 @@ pub(crate) fn move_object_with_terminal( // `valid_card: None` class is destination-gated to Graveyard), so // this is unreachable for the current pool — parked for // correctness if a future to-Hand redirect surfaces a choice. - replacement::park_waiting_for(state, player); + state.waiting_for = replacement::replacement_choice_waiting_for(player, state); ZoneMoveTerminalResult::NeedsChoice(player) } }; @@ -3134,7 +3134,7 @@ fn execute_zone_move_with_applied_terminal( // delivery-tail NeedsChoice path above is NOT parked here — its // wait state is already set by the counter-pause / devour machinery // (`replacement_pause_delivery_result` reads it). - replacement::park_waiting_for(state, player); + state.waiting_for = replacement::replacement_choice_waiting_for(player, state); ZoneMoveTerminalResult::NeedsChoice(player) } } diff --git a/crates/engine/src/game/zones.rs b/crates/engine/src/game/zones.rs index 4e094ea099..bf2112a9a5 100644 --- a/crates/engine/src/game/zones.rs +++ b/crates/engine/src/game/zones.rs @@ -4023,6 +4023,7 @@ mod tests { casting_restrictions: vec![], casting_options: vec![], layout_kind: Some(crate::types::card::LayoutKind::Modal), + parse_warnings: vec![], }); } @@ -4188,6 +4189,7 @@ mod tests { casting_restrictions: vec![], casting_options: vec![], layout_kind: Some(crate::types::card::LayoutKind::Modal), + parse_warnings: vec![], }); } // Apply back face (simulating ChooseModalFace on stack). diff --git a/crates/engine/src/parser/oracle_effect/subject.rs b/crates/engine/src/parser/oracle_effect/subject.rs index b6f37e533a..eedbe53139 100644 --- a/crates/engine/src/parser/oracle_effect/subject.rs +++ b/crates/engine/src/parser/oracle_effect/subject.rs @@ -40,7 +40,9 @@ use super::super::oracle_static::{ parse_continuous_modifications, parse_continuous_subject_filter, parse_static_line, parse_static_line_multi, peel_compound_all_quantified_conjuncts, }; -use super::super::oracle_target::{parse_target, parse_target_with_ctx, parse_type_phrase}; +use super::super::oracle_target::{ + parse_target, parse_target_with_ctx, parse_target_with_syntax, parse_type_phrase, TargetSyntax, +}; use super::super::oracle_util::{ merge_or_filters, parse_number, TextPair, SELF_REF_PARSE_ONLY_PHRASES, SELF_REF_TYPE_PHRASES, }; @@ -1195,8 +1197,48 @@ fn try_parse_subject_base_pt_set_clause_ast( .parse(parse_lower) .ok() }; - let (subject, axes, remainder) = - if let Some((rest_lower, (axes, _, subject_lower, _, _, _))) = inverted { + let targeted_inverted = if is_change { + // Transitive inverted genitive: "change the base power [and toughness] + // of target to " (Exuberant Wolfbear). The subject is + // a target phrase rather than a possessive, so retain its syntax: only + // the explicit `target` keyword creates a target slot on the effect. + // Parse the grammatical prefix before delegating the subject phrase to + // the shared target parser; then consume the transitive copula from + // that parser's exact remainder so a `to` inside the subject cannot be + // mistaken for the value boundary. + preceded( + tag::<_, _, VE>("the "), + terminated(parse_base_pt_axes, tag(" of ")), + ) + .parse(parse_lower) + .ok() + .and_then(|(after_of_lower, axes)| { + let target_text = &parse_body[parse_body.len() - after_of_lower.len()..]; + let (filter, target_remainder, syntax) = parse_target_with_syntax(target_text, ctx); + if !matches!(syntax, TargetSyntax::TargetKeyword) { + // Durationless inverted-transitive descriptor effects (such as + // Brine Hag) need duration provenance the existing + // `GenericEffect` representation cannot yet express. Keep + // them unsupported rather than lowering them incorrectly as + // until-end-of-turn effects; this targeted arm is for cards + // such as Exuberant Wolfbear with an explicit duration. + return None; + } + let target_remainder_lower = target_remainder.to_lowercase(); + let (after_to_lower, _) = tag::<_, _, VE>(" to ") + .parse(target_remainder_lower.as_str()) + .ok()?; + let remainder = &target_remainder[target_remainder.len() - after_to_lower.len()..]; + let application = subject_filter_application(filter, true)?; + Some((axes, remainder, application)) + }) + } else { + None + }; + let (subject, axes, remainder, target_application) = + if let Some((axes, remainder, application)) = targeted_inverted { + ("", axes, remainder, Some(application)) + } else if let Some((rest_lower, (axes, _, subject_lower, _, _, _))) = inverted { // `subject_lower` is a sub-slice of `parse_lower`; its byte offset is // the pointer delta. Recover original case at the same span. let subject_start = subject_lower.as_ptr() as usize - parse_lower.as_ptr() as usize; @@ -1204,7 +1246,7 @@ fn try_parse_subject_base_pt_set_clause_ast( .get(subject_start..subject_start + subject_lower.len())? .trim(); let remainder = &parse_body[parse_body.len() - rest_lower.len()..]; - (subject, axes, remainder) + (subject, axes, remainder, None) } else { // Possessive: "'s base power [and toughness]" followed by the // copula (" to " for the transitive "change" frame, else "become[s] "). @@ -1230,7 +1272,7 @@ fn try_parse_subject_base_pt_set_clause_ast( let (rest_lower, ()) = parse_base_pt_copula(rest_lower, is_change).ok()?; let subject = parse_body[..subject_lower.len()].trim(); let remainder = &parse_body[parse_body.len() - rest_lower.len()..]; - (subject, axes, remainder) + (subject, axes, remainder, None) }; // Parse the value side. The transitive "change … to" frame carries a bare @@ -1249,7 +1291,7 @@ fn try_parse_subject_base_pt_set_clause_ast( // Parse the optional trailing keyword-grant conjunct ("and they gain trample"). let keywords = parse_base_pt_set_trailing_keywords(after_pt); - let application = parse_subject_application(subject, ctx)?; + let application = target_application.or_else(|| parse_subject_application(subject, ctx))?; let affected = static_affected_for_application(&application); // CR 208.1 + CR 613.4b: emit per-axis layer-7b set modifications. Fixed diff --git a/crates/engine/src/parser/oracle_ir/feature.rs b/crates/engine/src/parser/oracle_ir/feature.rs index 4edd371b75..0d78cca00e 100644 --- a/crates/engine/src/parser/oracle_ir/feature.rs +++ b/crates/engine/src/parser/oracle_ir/feature.rs @@ -689,7 +689,7 @@ mod tests { /// checkout does not have. Point it at one and run it explicitly: /// /// ```text -/// ORACLE_POOL_DIR=/path/to/data cargo test -p engine --lib pool_structure_census \ +/// ORACLE_POOL_DIR=/path/to/data cargo test -p phase-engine --lib pool_structure_census \ /// -- --ignored --nocapture /// ``` #[cfg(test)] diff --git a/crates/engine/src/parser/oracle_tests.rs b/crates/engine/src/parser/oracle_tests.rs index f0020adc7f..8e01aefb99 100644 --- a/crates/engine/src/parser/oracle_tests.rs +++ b/crates/engine/src/parser/oracle_tests.rs @@ -2839,6 +2839,125 @@ fn sita_varma_full_card_supported_inverted_genitive_base_pt() { ); } +/// CR 603.2 + CR 115.1 + CR 208.1 + CR 613.4b: Exuberant Wolfbear's attack +/// trigger uses the transitive inverted-genitive base-P/T form, "change the +/// base power and toughness of target Human you control to this creature's +/// power and toughness." The target must remain a real target slot (rather +/// than a resolution-time descriptor), and each axis reads the attacking source. +#[test] +fn exuberant_wolfbear_full_oracle_attack_trigger_targets_human_and_sets_dynamic_base_pt() { + const ORACLE: &str = "Whenever this creature attacks, you may change the base power and toughness of target Human you control to this creature's power and toughness until end of turn."; + + let parsed = parse( + ORACLE, + "Exuberant Wolfbear", + &[], + &["Creature"], + &["Wolf", "Bear"], + ); + assert_eq!( + parsed.triggers.len(), + 1, + "expected one attack trigger: {parsed:#?}" + ); + assert!( + parsed + .abilities + .iter() + .all(|ability| !matches!(ability.effect.as_ref(), Effect::Unimplemented { .. })), + "Wolfbear must not produce standalone Unimplemented abilities: {parsed:#?}" + ); + + let trigger = &parsed.triggers[0]; + assert_eq!(trigger.mode, TriggerMode::Attacks); + assert!(trigger.optional, "'you may' must keep the trigger optional"); + let execute = trigger + .execute + .as_deref() + .expect("attack trigger must have an execute body"); + + fn assert_no_unimplemented(definition: &AbilityDefinition) { + assert!( + !matches!(definition.effect.as_ref(), Effect::Unimplemented { .. }), + "unexpected unimplemented effect: {definition:#?}" + ); + if let Some(sub_ability) = definition.sub_ability.as_deref() { + assert_no_unimplemented(sub_ability); + } + } + assert_no_unimplemented(execute); + + let Effect::GenericEffect { + static_abilities, + duration, + target, + .. + } = execute.effect.as_ref() + else { + panic!("expected base-P/T GenericEffect, got {:#?}", execute.effect); + }; + assert_eq!(*duration, Some(Duration::UntilEndOfTurn)); + assert_eq!( + target, + &Some(TargetFilter::Typed( + TypedFilter::new(TypeFilter::Subtype("Human".to_string())) + .controller(ControllerRef::You) + )), + "the explicit target keyword must produce a Human-you-control target slot" + ); + + let modifications = &static_abilities[0].modifications; + for (label, modification) in [ + ( + "power", + ContinuousModification::SetPowerDynamic { + value: QuantityExpr::Ref { + qty: QuantityRef::Power { + scope: ObjectScope::Source, + }, + }, + }, + ), + ( + "toughness", + ContinuousModification::SetToughnessDynamic { + value: QuantityExpr::Ref { + qty: QuantityRef::Toughness { + scope: ObjectScope::Source, + }, + }, + }, + ), + ] { + assert!( + modifications.contains(&modification), + "Wolfbear must set dynamic base {label} from the source: {modifications:#?}" + ); + } +} + +/// Regression: Brine Hag's durationless, descriptor-scoped inverted transitive +/// base-P/T effect must remain an explicit parser gap. Only the `target` +/// syntactic form can use the new target-slot lowering path. +#[test] +fn brine_hag_durationless_descriptor_base_pt_effect_remains_unimplemented() { + const ORACLE: &str = "When this creature dies, change the base power and toughness of all creatures that dealt damage to it this turn to 0/2. (This effect lasts indefinitely.)"; + + let parsed = parse(ORACLE, "Brine Hag", &[], &["Creature"], &[]); + assert_eq!( + parsed.triggers.len(), + 1, + "expected Brine Hag's dies trigger: {parsed:#?}" + ); + assert!( + has_unimplemented_mentioning( + &parsed, + "change the base power and toughness of all creatures that dealt damage to it this turn to 0/2", + ), + "the durationless descriptor form must not lower through the target-only path: {parsed:#?}" + ); +} + /// CR 601.2a + CR 609.4b + CR 601.3b: Azula, Cunning Usurper — the /// cast-from-exile static line must lower to a supported /// `StaticMode::ExileCastPermission` carrying the persistent Cast permission, diff --git a/crates/engine/src/parser/oracle_trigger.rs b/crates/engine/src/parser/oracle_trigger.rs index 75df6f4562..8e57131a91 100644 --- a/crates/engine/src/parser/oracle_trigger.rs +++ b/crates/engine/src/parser/oracle_trigger.rs @@ -7893,7 +7893,7 @@ fn split_cross_subject_event_compound(cond_lower: &str, condition: &str) -> Opti if is_enters_or_haunted_creature_dies_compound(cond_lower) { return None; } - let (after_lower, _) = parse_cross_subject_or_split(cond_lower).ok()?; + let (after_lower, before_lower) = parse_cross_subject_or_split(cond_lower).ok()?; let (after_original, before_original) = parse_cross_subject_or_split(condition).ok()?; // Check if what follows " or " starts with a valid subject phrase @@ -7923,6 +7923,38 @@ fn split_cross_subject_event_compound(cond_lower: &str, condition: &str) -> Opti // Both yield a bogus first half with no event at all. scan_preceded(after_trimmed, |i| parse_event_verb_start(i))?; + // CR 603.1 + CR 603.2: symmetric gate — a genuine CROSS-SUBJECT compound + // carries an event on BOTH sides of the "or" (Norin the Wary: "a player + // casts a spell" / "a creature attacks"). A SUBJECT disjunction shares one + // event between two subjects, so its first half is a bare noun phrase + // ("whenever ~", "whenever this creature"). Splitting that strands the + // leading subject in a trigger with no event at all and silently drops the + // self leg, so the card fires only for its second subject. + // + // This is the same `Unknown("Whenever ~")` husk the comment above keeps the + // SECOND-half gate narrow to avoid; the two gates close the failure from + // opposite sides. Donna Noble and The Bus Runner never reach this arm (their + // second half carries no narrow-lexicon verb, so the gate above already + // declines) and are unaffected here. What slips past that gate is a subject + // disjunction whose second subject IS followed by an active-voice verb — + // Ironsoul Enforcer's "or a commander you control attacks alone", which the + // scan above cannot tell apart from a second event. + // `parse_trigger_subject` already folds these into one `TargetFilter::Or` + // subject, which is the CR 603.1-correct single trigger. + // + // Measured over the MTGJSON corpus, this recovers the dropped leading leg on + // Ironsoul Enforcer, Campsite Cuisine, Calix, Syr Carah, Shipwreck Sifters, + // Long Feng and Surrak — four distinct event families (Attacks, ChangesZone, + // DamageDone, BecomesTarget) — and splits zero lines it split before. + // + // This gate uses the WIDE `parse_event_head_start` on purpose: it must + // decline only when the leading half has NO event of any kind. A + // state-change or passive leading leg ("whenever ~ becomes tapped or a + // creature you control attacks") is still a real two-event compound and + // must keep splitting, so narrowing this to `parse_event_verb_start` would + // collapse it into a bogus single trigger. + scan_preceded(before_lower.trim(), |i| parse_event_head_start(i))?; + let (_, keyword) = parse_trigger_keyword_prefix(cond_lower).ok()?; let first = before_original.trim().to_string(); diff --git a/crates/engine/src/parser/oracle_trigger_tests.rs b/crates/engine/src/parser/oracle_trigger_tests.rs index c73a6d1bdf..dbadaab783 100644 --- a/crates/engine/src/parser/oracle_trigger_tests.rs +++ b/crates/engine/src/parser/oracle_trigger_tests.rs @@ -4088,6 +4088,93 @@ fn trigger_attacks_you_or_planeswalker_not_split() { ); } +/// CR 603.1 + CR 603.2: a SUBJECT disjunction (" or ") is +/// one trigger with an `Or` subject, not a cross-subject compound. The +/// second-half event-verb gate in `split_cross_subject_event_compound` cannot +/// tell the two apart on its own — Ironsoul Enforcer's second subject is +/// followed by "attacks", a narrow-lexicon active-voice verb — so the split +/// also requires an event in the FIRST half. Without that symmetric gate the +/// line splits into `Unknown("Whenever ~")` + the commander leg, silently +/// dropping the self leg so the card never fires off its own attack. +/// +/// This is the production path (`parse_trigger_lines`); `parse_trigger_line` +/// alone never reaches the splitter and so cannot catch the regression. +#[test] +fn trigger_self_or_class_subject_disjunction_not_split() { + let triggers = parse_trigger_lines( + "Whenever this creature or a commander you control attacks alone, return target artifact card from your graveyard to the battlefield.", + "Ironsoul Enforcer", + ); + assert_eq!( + triggers.len(), + 1, + "a shared-event subject disjunction must stay one trigger: {:?}", + triggers.iter().map(|t| &t.mode).collect::>() + ); + assert_eq!(triggers[0].mode, TriggerMode::Attacks); + let Some(TargetFilter::Or { filters }) = triggers[0].valid_card.as_ref() else { + panic!("expected an Or subject, got {:?}", triggers[0].valid_card); + }; + assert_eq!(filters.len(), 2, "one leg per subject: {filters:?}"); + assert_eq!(filters[0], TargetFilter::SelfRef); + assert!( + matches!(&filters[1], TargetFilter::Typed(tf) + if tf.properties.contains(&FilterProp::IsCommander)), + "CR 903.3: second leg must carry IsCommander, got {:?}", + filters[1] + ); + assert_eq!( + triggers[0].condition, + Some(TriggerCondition::Not { + condition: Box::new(TriggerCondition::MinCoAttackers { + minimum: 1, + filter: None, + }), + }), + "CR 506.5: the alone gate applies to whichever subject attacked" + ); +} + +/// CR 603.1: the same gate, a different event family — the fix must be +/// class-level, not attack-specific. Campsite Cuisine's "this enchantment or a +/// legendary creature you control enters" shares one ETB event between two +/// subjects; before the first-half gate it split into `Unknown("Whenever ~")` +/// plus the legendary-creature leg, so the enchantment's own ETB never fired. +#[test] +fn trigger_self_or_class_subject_disjunction_not_split_etb() { + let triggers = parse_trigger_lines( + "Whenever this enchantment or a legendary creature you control enters, create a Food token.", + "Campsite Cuisine", + ); + assert_eq!( + triggers.len(), + 1, + "a shared-ETB subject disjunction must stay one trigger: {:?}", + triggers.iter().map(|t| &t.mode).collect::>() + ); + assert_eq!(triggers[0].mode, TriggerMode::ChangesZone); + assert_eq!(triggers[0].destination, Some(Zone::Battlefield)); + let Some(TargetFilter::Or { filters }) = triggers[0].valid_card.as_ref() else { + panic!("expected an Or subject, got {:?}", triggers[0].valid_card); + }; + assert_eq!(filters.len(), 2, "one leg per subject: {filters:?}"); + assert_eq!( + filters[0], + TargetFilter::SelfRef, + "the source's own ETB leg must survive the split gate" + ); + assert!( + matches!(&filters[1], TargetFilter::Typed(tf) + if tf.controller == Some(ControllerRef::You) + && tf.type_filters == vec![TypeFilter::Creature] + && tf.properties.contains(&FilterProp::HasSupertype { + value: Supertype::Legendary + })), + "second leg must be a legendary creature you control, got {:?}", + filters[1] + ); +} + #[test] fn trigger_counter_put_on_self() { let def = parse_trigger_line( @@ -14835,6 +14922,50 @@ fn trigger_dragon_you_control_attacks() { ); } +/// CR 506.5 + CR 903.3: a *disjunctive* attacks-alone subject — a +/// self-reference OR'd with a non-self class (Ironsoul Enforcer's "this +/// creature or a commander you control"). Two building blocks must compose: +/// `parse_trigger_subject`'s `" or "` fold must produce an `Or` whose second +/// leg carries `FilterProp::IsCommander` (the CR 903.3 designation, NOT a +/// subtype), and `strip_attack_alone_qualifier` must still gate the whole +/// trigger on zero co-attackers. The alone-gate lives on the condition, not on +/// either leg, so it applies to whichever disjunct matched. +#[test] +fn trigger_self_or_commander_attacks_alone() { + let def = parse_trigger_line( + "Whenever this creature or a commander you control attacks alone, return target artifact card from your graveyard to the battlefield.", + "Ironsoul Enforcer", + ); + assert!(matches!(def.mode, TriggerMode::Attacks)); + let Some(TargetFilter::Or { filters }) = def.valid_card.as_ref() else { + panic!("expected a disjunctive subject, got {:?}", def.valid_card); + }; + assert_eq!(filters.len(), 2, "one leg per disjunct: {filters:?}"); + assert_eq!( + filters[0], + TargetFilter::SelfRef, + "\"this creature\" must bind to the trigger source" + ); + let TargetFilter::Typed(commander) = &filters[1] else { + panic!("expected a typed commander leg, got {:?}", filters[1]); + }; + assert_eq!(commander.controller, Some(ControllerRef::You)); + assert!( + commander.properties.contains(&FilterProp::IsCommander), + "CR 903.3: \"commander\" is the IsCommander designation, not a subtype: {commander:?}" + ); + assert_eq!( + def.condition, + Some(TriggerCondition::Not { + condition: Box::new(TriggerCondition::MinCoAttackers { + minimum: 1, + filter: None, + }), + }), + "CR 506.5: the alone gate must survive the disjunctive subject" + ); +} + #[test] fn trigger_samurai_or_warrior_attacks_alone() { let def = parse_trigger_line( diff --git a/crates/engine/src/types/game_state_size.rs b/crates/engine/src/types/game_state_size.rs index 05961d41a7..f7ef80dc73 100644 --- a/crates/engine/src/types/game_state_size.rs +++ b/crates/engine/src/types/game_state_size.rs @@ -30,7 +30,7 @@ //! //! ```text //! CARGO_TARGET_DIR=/tmp/gs-size RUSTFLAGS="-Zprint-type-sizes" \ -//! cargo build -p engine --lib \ +//! cargo build -p phase-engine --lib \ //! | grep 'print-type-size type: `types::game_state::GameState`:' //! ``` //! diff --git a/crates/engine/tests/fixtures/dina_noff_turn5_4p.json.gz b/crates/engine/tests/fixtures/dina_noff_turn5_4p.json.gz new file mode 100644 index 0000000000..169ab59d3d Binary files /dev/null and b/crates/engine/tests/fixtures/dina_noff_turn5_4p.json.gz differ diff --git a/crates/engine/tests/integration/azors_gateway_transform_condition.rs b/crates/engine/tests/integration/azors_gateway_transform_condition.rs index bc19a636bb..6ff8f8ed04 100644 --- a/crates/engine/tests/integration/azors_gateway_transform_condition.rs +++ b/crates/engine/tests/integration/azors_gateway_transform_condition.rs @@ -68,6 +68,7 @@ fn sanctum_of_the_sun_back_face() -> BackFaceData { casting_restrictions: vec![], casting_options: vec![], layout_kind: None, + parse_warnings: vec![], } } diff --git a/crates/engine/tests/integration/coalition_relic_integration.rs b/crates/engine/tests/integration/coalition_relic_integration.rs index f1bc91b9ec..c1949e8cd1 100644 --- a/crates/engine/tests/integration/coalition_relic_integration.rs +++ b/crates/engine/tests/integration/coalition_relic_integration.rs @@ -235,4 +235,12 @@ fn coalition_relic_with_zero_charge_counters_produces_no_mana() { 0, "zero counters → zero mana" ); + assert!( + matches!( + state.waiting_for, + engine::types::game_state::WaitingFor::Priority { .. } + ), + "zero counters must not prompt for a mana color; got {:?}", + state.waiting_for + ); } diff --git a/crates/engine/tests/integration/copied_ability_transform_generation.rs b/crates/engine/tests/integration/copied_ability_transform_generation.rs index 250d1ff995..bf3af9c4c4 100644 --- a/crates/engine/tests/integration/copied_ability_transform_generation.rs +++ b/crates/engine/tests/integration/copied_ability_transform_generation.rs @@ -83,6 +83,7 @@ fn stonewing_antagonizer_back_face() -> BackFaceData { casting_restrictions: vec![], casting_options: vec![], layout_kind: None, + parse_warnings: vec![], } } diff --git a/crates/engine/tests/integration/craft_tithing_blade_transform.rs b/crates/engine/tests/integration/craft_tithing_blade_transform.rs index 78190502e1..81f9dbe73f 100644 --- a/crates/engine/tests/integration/craft_tithing_blade_transform.rs +++ b/crates/engine/tests/integration/craft_tithing_blade_transform.rs @@ -391,6 +391,7 @@ fn consuming_sepulcher_back_face() -> BackFaceData { casting_restrictions: vec![], casting_options: vec![], layout_kind: None, + parse_warnings: vec![], } } diff --git a/crates/engine/tests/integration/deterministic_game_state_serde.rs b/crates/engine/tests/integration/deterministic_game_state_serde.rs index eed8182619..f97aba069d 100644 --- a/crates/engine/tests/integration/deterministic_game_state_serde.rs +++ b/crates/engine/tests/integration/deterministic_game_state_serde.rs @@ -1191,6 +1191,7 @@ fn back_face(name: &str) -> BackFaceData { casting_restrictions: Vec::new(), casting_options: Vec::new(), layout_kind: None, + parse_warnings: vec![], } } diff --git a/crates/engine/tests/integration/exuberant_wolfbear_base_pt_target.rs b/crates/engine/tests/integration/exuberant_wolfbear_base_pt_target.rs new file mode 100644 index 0000000000..e919071fac --- /dev/null +++ b/crates/engine/tests/integration/exuberant_wolfbear_base_pt_target.rs @@ -0,0 +1,233 @@ +//! Exuberant Wolfbear's attack trigger targets a Human its controller controls +//! and sets that Human's base power and toughness to the attacker's current P/T +//! until end of turn. + +use engine::game::scenario::{GameRunner, GameScenario, P0, P1}; +use engine::types::actions::GameAction; +use engine::types::game_state::WaitingFor; +use engine::types::identifiers::ObjectId; +use engine::types::phase::Phase; + +use super::rules::AttackTarget; + +const WOLFBEAR_ORACLE: &str = "Whenever this creature attacks, you may change the base power and toughness of target Human you control to this creature's power and toughness until end of turn."; + +fn power_toughness(runner: &GameRunner, object_id: ObjectId) -> (Option, Option) { + let object = runner + .state() + .objects + .get(&object_id) + .expect("test permanent must remain on the battlefield"); + (object.power, object.toughness) +} + +fn setup() -> (GameRunner, ObjectId, ObjectId, ObjectId, ObjectId) { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let wolfbear = scenario + .add_creature_from_oracle(P0, "Exuberant Wolfbear", 7, 5, WOLFBEAR_ORACLE) + .id(); + let chosen_human = scenario + .add_creature(P0, "Chosen Human", 1, 1) + .with_subtypes(vec!["Human"]) + .id(); + let other_human = scenario + .add_creature(P0, "Other Human", 1, 1) + .with_subtypes(vec!["Human"]) + .id(); + let opponent_human = scenario + .add_creature(P1, "Opponent Human", 1, 1) + .with_subtypes(vec!["Human"]) + .id(); + + ( + scenario.build(), + wolfbear, + chosen_human, + other_human, + opponent_human, + ) +} + +fn attack_until_target_selection(runner: &mut GameRunner, wolfbear: ObjectId) { + runner.pass_both_players(); + runner + .act(GameAction::DeclareAttackers { + attacks: vec![(wolfbear, AttackTarget::Player(P1))], + bands: vec![], + }) + .expect("Wolfbear attack declaration must succeed"); + + for _ in 0..16 { + match runner.state().waiting_for.clone() { + WaitingFor::TriggerTargetSelection { source_id, .. } => { + assert_eq!( + source_id, + Some(wolfbear), + "Wolfbear must own the target prompt" + ); + return; + } + WaitingFor::OrderTriggers { .. } => { + engine::game::triggers::drain_order_triggers_with_identity(runner.state_mut()); + } + WaitingFor::Priority { .. } => runner.pass_both_players(), + other => { + panic!("expected Wolfbear's target selection, got waiting state {other:?}") + } + } + } + panic!("Wolfbear attack trigger did not reach target selection"); +} + +fn target_selected_until_optional_choice(runner: &mut GameRunner, wolfbear: ObjectId) { + for _ in 0..16 { + match runner.state().waiting_for.clone() { + WaitingFor::OptionalEffectChoice { source_id, .. } => { + assert_eq!(source_id, wolfbear, "Wolfbear must own the may prompt"); + return; + } + WaitingFor::OrderTriggers { .. } => { + engine::game::triggers::drain_order_triggers_with_identity(runner.state_mut()); + } + WaitingFor::Priority { .. } => runner.pass_both_players(), + other => panic!("expected Wolfbear's optional choice, got waiting state {other:?}"), + } + } + panic!("Wolfbear attack trigger did not reach its optional choice"); +} + +fn finish_current_turn(runner: &mut GameRunner, turn_number: u32) { + for _ in 0..32 { + if runner.state().turn_number > turn_number { + return; + } + match runner.state().waiting_for.clone() { + WaitingFor::Priority { .. } => runner.pass_both_players(), + WaitingFor::DeclareBlockers { .. } => { + runner + .act(GameAction::DeclareBlockers { + assignments: vec![], + }) + .expect("defender may declare no blockers"); + } + WaitingFor::OrderTriggers { .. } => { + engine::game::triggers::drain_order_triggers_with_identity(runner.state_mut()); + } + other => panic!("unexpected state while advancing to cleanup: {other:?}"), + } + } + panic!("turn did not advance through cleanup"); +} + +#[test] +fn exuberant_wolfbear_accepts_only_controlled_humans_and_expires_at_cleanup() { + let (mut runner, wolfbear, chosen_human, other_human, opponent_human) = setup(); + let current_turn = runner.state().turn_number; + + attack_until_target_selection(&mut runner, wolfbear); + + let chosen_target = match runner.state().waiting_for.clone() { + WaitingFor::TriggerTargetSelection { + target_slots, + selection, + .. + } => { + let legal_targets = &target_slots[selection.current_slot].legal_targets; + let legal_object_ids: Vec<_> = legal_targets + .iter() + .filter_map(|target| match target { + engine::types::ability::TargetRef::Object(id) => Some(*id), + engine::types::ability::TargetRef::Player(_) => None, + }) + .collect(); + assert!( + legal_object_ids.contains(&chosen_human) && legal_object_ids.contains(&other_human), + "both controlled Humans must be legal targets: {legal_targets:?}" + ); + assert!( + !legal_object_ids.contains(&opponent_human), + "opponent's Human must not be a legal target: {legal_targets:?}" + ); + legal_targets + .iter() + .find(|target| { + matches!(target, engine::types::ability::TargetRef::Object(id) if *id == chosen_human) + }) + .cloned() + .expect("chosen controlled Human must be offered by the engine") + } + other => panic!("expected target selection after accepting Wolfbear: {other:?}"), + }; + runner + .act(GameAction::ChooseTarget { + target: Some(chosen_target), + }) + .expect("select the engine-offered controlled Human target"); + target_selected_until_optional_choice(&mut runner, wolfbear); + runner + .act(GameAction::DecideOptionalEffect { accept: true }) + .expect("controller may accept Wolfbear's trigger"); + runner.advance_until_stack_empty(); + + assert_eq!( + power_toughness(&runner, chosen_human), + (Some(7), Some(5)), + "selected Human must take Wolfbear's 7/5 base P/T this turn" + ); + assert_eq!( + power_toughness(&runner, other_human), + (Some(1), Some(1)), + "unchosen controlled Human must stay 1/1" + ); + assert_eq!( + power_toughness(&runner, opponent_human), + (Some(1), Some(1)), + "opponent's Human must stay 1/1" + ); + + finish_current_turn(&mut runner, current_turn); + assert_eq!( + power_toughness(&runner, chosen_human), + (Some(1), Some(1)), + "until-end-of-turn base P/T must expire during cleanup" + ); +} + +#[test] +fn exuberant_wolfbear_decline_leaves_controlled_human_unchanged() { + let (mut runner, wolfbear, chosen_human, _other_human, _opponent_human) = setup(); + + attack_until_target_selection(&mut runner, wolfbear); + let chosen_target = match runner.state().waiting_for.clone() { + WaitingFor::TriggerTargetSelection { + target_slots, + selection, + .. + } => target_slots[selection.current_slot] + .legal_targets + .iter() + .find(|target| { + matches!(target, engine::types::ability::TargetRef::Object(id) if *id == chosen_human) + }) + .cloned() + .expect("chosen controlled Human must be offered by the engine"), + other => panic!("expected target selection before optional choice: {other:?}"), + }; + runner + .act(GameAction::ChooseTarget { + target: Some(chosen_target), + }) + .expect("select the engine-offered controlled Human target"); + target_selected_until_optional_choice(&mut runner, wolfbear); + runner + .act(GameAction::DecideOptionalEffect { accept: false }) + .expect("controller may decline Wolfbear's trigger"); + runner.advance_until_stack_empty(); + + assert_eq!( + power_toughness(&runner, chosen_human), + (Some(1), Some(1)), + "declining Wolfbear's optional trigger must leave the Human unchanged" + ); +} diff --git a/crates/engine/tests/integration/integration_adventure.rs b/crates/engine/tests/integration/integration_adventure.rs index 70458bf2bc..d25b69a7ab 100644 --- a/crates/engine/tests/integration/integration_adventure.rs +++ b/crates/engine/tests/integration/integration_adventure.rs @@ -93,6 +93,7 @@ fn stomp_back_face() -> BackFaceData { casting_restrictions: Vec::new(), casting_options: Vec::new(), layout_kind: None, + parse_warnings: vec![], } } diff --git a/crates/engine/tests/integration/ironsoul_enforcer_commander_attacks_alone.rs b/crates/engine/tests/integration/ironsoul_enforcer_commander_attacks_alone.rs new file mode 100644 index 0000000000..f921e23675 --- /dev/null +++ b/crates/engine/tests/integration/ironsoul_enforcer_commander_attacks_alone.rs @@ -0,0 +1,120 @@ +//! Ironsoul Enforcer — "Whenever this creature or a commander you control +//! attacks alone, return target artifact card from your graveyard to the +//! battlefield." +//! +//! The building block under test is a *disjunctive* attacks-alone trigger +//! subject: a self-reference OR'd with a non-self class (here CR 903.3's +//! commander designation). Two axes must hold together: +//! +//! * CR 506.5 — "attacks alone" is evaluated against the creature that was +//! declared as an attacker, not against the ability's source. When the +//! commander attacks alone the Enforcer is not in combat at all, so the +//! co-attacker tally must exclude the *commander*, not the Enforcer. +//! * CR 903.3 — "commander" is a deck-construction designation carried by +//! `is_commander`, not a creature subtype. A creature that is not flagged +//! must not satisfy the second disjunct. +//! +//! Covers the whole ` or attacks alone` class (Ironsoul Enforcer +//! and any future card whose attacks-alone subject is a disjunction), not just +//! this card. + +use engine::types::phase::Phase; + +use super::rules::{run_combat, GameScenario, P0}; + +const ORACLE: &str = "Whenever this creature or a commander you control attacks alone, return target artifact card from your graveyard to the battlefield."; + +/// Which creatures P0 declares as attackers in the fixture. +#[derive(Clone, Copy)] +enum Attack { + /// The commander attacks by itself; the Enforcer stays home. + CommanderAlone, + /// The Enforcer attacks by itself; the commander stays home. + EnforcerAlone, + /// Both attack — no creature attacks alone. + Both, + /// A non-commander bear attacks by itself — neither disjunct matches. + NonCommanderAlone, +} + +/// Returns whether the graveyard artifact ("Ornithopter") reached the +/// battlefield, i.e. whether the trigger fired and resolved. +fn artifact_returned(attack: Attack) -> bool { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + + let enforcer = scenario + .add_creature_from_oracle(P0, "Ironsoul Enforcer", 4, 4, ORACLE) + .id(); + let commander = scenario + .add_creature(P0, "Kediss, Emberclaw Familiar", 2, 2) + .id(); + let bear = scenario.add_creature(P0, "Grizzly Bears", 2, 2).id(); + + // The reanimation target: an artifact card in P0's graveyard. + let ornithopter = scenario + .add_creature_to_graveyard(P0, "Ornithopter", 0, 2) + .as_artifact() + .id(); + + let mut runner = scenario.build(); + + // CR 903.3: the commander designation is a per-object flag, not a subtype. + // Mirrors `make_commander` in `rules/layers.rs` — owner == controller == P0 + // with the object on the battlefield, so `FilterProp::IsCommander` matches. + runner + .state_mut() + .objects + .get_mut(&commander) + .expect("commander object exists") + .is_commander = true; + + let attackers = match attack { + Attack::CommanderAlone => vec![commander], + Attack::EnforcerAlone => vec![enforcer], + Attack::Both => vec![commander, enforcer], + Attack::NonCommanderAlone => vec![bear], + }; + + run_combat(&mut runner, attackers, vec![]); + runner.advance_until_stack_empty(); + + runner.state().battlefield.contains(&ornithopter) +} + +#[test] +fn commander_attacking_alone_fires_while_the_enforcer_stays_home() { + // CR 506.5: the commander is the only declared attacker, so it attacks + // alone even though the trigger's source is not in combat. + assert!( + artifact_returned(Attack::CommanderAlone), + "CR 506.5 + CR 903.3: a lone commander attacker must satisfy the second \ + disjunct and return the graveyard artifact" + ); +} + +#[test] +fn enforcer_attacking_alone_fires_on_the_self_disjunct() { + assert!( + artifact_returned(Attack::EnforcerAlone), + "CR 506.5: the self-reference disjunct must still fire when the source \ + itself is the lone attacker" + ); +} + +#[test] +fn two_attackers_do_not_attack_alone() { + assert!( + !artifact_returned(Attack::Both), + "CR 506.5: with two declared attackers neither creature attacks alone" + ); +} + +#[test] +fn non_commander_attacking_alone_does_not_fire() { + assert!( + !artifact_returned(Attack::NonCommanderAlone), + "CR 903.3: an unflagged creature is not a commander, so a lone \ + non-commander attacker matches neither disjunct" + ); +} diff --git a/crates/engine/tests/integration/issue_1515_emperor_of_bones.rs b/crates/engine/tests/integration/issue_1515_emperor_of_bones.rs index 4594815bfd..e0085b9eea 100644 --- a/crates/engine/tests/integration/issue_1515_emperor_of_bones.rs +++ b/crates/engine/tests/integration/issue_1515_emperor_of_bones.rs @@ -6,19 +6,32 @@ use engine::game::effects::resolve_ability_chain; use engine::game::scenario::{GameScenario, P0}; use engine::parser::oracle_effect::parse_effect_chain; use engine::types::ability::{ - AbilityKind, ContinuousModification, DelayedTriggerCondition, Effect, TargetFilter, + AbilityCost, AbilityDefinition, AbilityKind, ChoiceType, ChosenAttribute, + ContinuousModification, DelayedTriggerCondition, Effect, QuantityExpr, QuantityRef, + ReplacementDefinition, TargetFilter, }; +use engine::types::actions::GameAction; use engine::types::counter::CounterType; use engine::types::game_state::{ExileLink, ExileLinkKind, WaitingFor}; use engine::types::identifiers::ObjectId; use engine::types::keywords::Keyword; use engine::types::phase::Phase; +use engine::types::player::PlayerId; +use engine::types::replacements::ReplacementEvent; use engine::types::zones::Zone; const EMPEROR_COUNTER_TRIGGER_EFFECT: &str = "put a creature card exiled with this creature onto \ the battlefield under your control with a finality counter on it. it gains haste. sacrifice it at \ the beginning of the next end step."; +const ANOINTED_PEACEKEEPER: &str = "Vigilance\n\ +As this creature enters, look at an opponent's hand, then choose any card name.\n\ +Spells your opponents cast with the chosen name cost {2} more to cast.\n\ +Activated abilities of sources with the chosen name cost {2} more to activate unless they're mana abilities."; + +const P1: PlayerId = PlayerId(1); +const NAMED_CARD: &str = "Llanowar Elves"; + fn creature_has_haste_from_transient_effects( state: &engine::types::game_state::GameState, creature: ObjectId, @@ -153,3 +166,179 @@ fn issue_1515_emperor_of_bones_binds_haste_and_delayed_sacrifice_to_returned_cre "the delayed sacrifice must not sacrifice Emperor" ); } + +/// CR 614.12a + CR 400.7j: An as-enters choice on the returned permanent must +/// complete without losing later instructions that refer to that permanent. +#[test] +fn emperor_of_bones_resumes_riders_after_anointed_peacekeepers_as_enters_choices() { + let mut scenario = GameScenario::new_n_player(2, 7); + scenario.at_phase(Phase::PreCombatMain); + let emperor = scenario.add_creature(P0, "Emperor of Bones", 2, 2).id(); + let _opponent_card = scenario.add_card_to_hand(P1, "Opponent Secret"); + let peacekeeper = { + let mut builder = scenario.add_creature_to_exile(P0, "Anointed Peacekeeper", 3, 3); + builder.from_oracle_text(ANOINTED_PEACEKEEPER); + builder.id() + }; + + let mut runner = scenario.build(); + runner.state_mut().all_card_names = std::sync::Arc::from([NAMED_CARD.to_string()]); + runner.state_mut().exile_links.push(ExileLink { + exiled_id: peacekeeper, + source_id: emperor, + kind: ExileLinkKind::TrackedBySource, + }); + + let definition = parse_effect_chain(EMPEROR_COUNTER_TRIGGER_EFFECT, AbilityKind::Spell); + let ability = build_resolved_from_def(&definition, emperor, P0); + let mut events = Vec::new(); + resolve_ability_chain(runner.state_mut(), &ability, &mut events, 0) + .expect("Emperor of Bones return must reach Peacekeeper's as-enters choice"); + + let WaitingFor::NamedChoice { + choice_type, + options, + .. + } = runner.state().waiting_for.clone() + else { + panic!( + "Peacekeeper must ask which opponent to look at, got {}", + runner.waiting_for_kind() + ); + }; + assert!(matches!(choice_type, ChoiceType::Opponent { .. })); + assert_eq!(options, vec![P1.0.to_string()]); + runner + .act(GameAction::ChooseOption { + choice: P1.0.to_string(), + }) + .expect("choose the opponent whose hand Peacekeeper looks at"); + + let WaitingFor::NamedChoice { choice_type, .. } = runner.state().waiting_for.clone() else { + panic!( + "Peacekeeper must ask for a card name after looking, got {}", + runner.waiting_for_kind() + ); + }; + assert!(matches!(choice_type, ChoiceType::CardName)); + runner + .act(GameAction::ChooseOption { + choice: NAMED_CARD.to_string(), + }) + .expect("choose the card name for Peacekeeper"); + + let state = runner.state(); + let returned = &state.objects[&peacekeeper]; + assert_eq!(returned.zone, Zone::Battlefield); + assert!(returned.chosen_attributes.iter().any( + |attribute| matches!(attribute, ChosenAttribute::CardName(name) if name == NAMED_CARD) + )); + assert_eq!( + returned + .counters + .get(&CounterType::Finality) + .copied() + .unwrap_or(0), + 1, + "Peacekeeper must retain Emperor's finality entry modifier" + ); + assert!( + creature_has_haste_from_transient_effects(state, peacekeeper), + "Emperor's forwarded haste rider must resume after both as-enters choices" + ); + assert_eq!( + state.delayed_triggers.len(), + 1, + "Emperor's delayed sacrifice rider must resume after both as-enters choices" + ); + assert_eq!( + state.delayed_triggers[0].ability.targets, + vec![engine::types::ability::TargetRef::Object(peacekeeper)] + ); +} + +/// A synthetic as-enters replacement that opens a PayAmountChoice before the +/// returning permanent finishes entering. This mirrors the shape of a printed +/// Moved replacement while keeping the regression independent of card data. +fn pay_amount_choice_replacement() -> ReplacementDefinition { + ReplacementDefinition::new(ReplacementEvent::Moved) + .destination_zone(Zone::Battlefield) + .valid_card(TargetFilter::SelfRef) + .execute(AbilityDefinition::new( + AbilityKind::Spell, + Effect::PayCost { + cost: AbilityCost::PayLife { + amount: QuantityExpr::Ref { + qty: QuantityRef::Variable { + name: "X".to_string(), + }, + }, + }, + scale: None, + payer: TargetFilter::Controller, + }, + )) +} + +/// CR 614.12a + CR 400.7j: A previously unlisted resolution-owned prompt must +/// survive the replacement pause and resume the Emperor continuation. +#[test] +fn emperor_of_bones_preserves_pay_amount_choice_through_replacement_pipeline() { + let mut scenario = GameScenario::new_n_player(2, 7); + scenario.at_phase(Phase::PreCombatMain); + let emperor = scenario.add_creature(P0, "Emperor of Bones", 2, 2).id(); + let peacekeeper = { + let mut builder = scenario.add_creature_to_exile(P0, "Anointed Peacekeeper", 3, 3); + builder.from_oracle_text(ANOINTED_PEACEKEEPER); + builder.id() + }; + + let mut runner = scenario.build(); + runner.state_mut().exile_links.push(ExileLink { + exiled_id: peacekeeper, + source_id: emperor, + kind: ExileLinkKind::TrackedBySource, + }); + runner + .state_mut() + .objects + .get_mut(&peacekeeper) + .unwrap() + .replacement_definitions = vec![pay_amount_choice_replacement()].into(); + + let definition = parse_effect_chain(EMPEROR_COUNTER_TRIGGER_EFFECT, AbilityKind::Spell); + let ability = build_resolved_from_def(&definition, emperor, P0); + let mut events = Vec::new(); + resolve_ability_chain(runner.state_mut(), &ability, &mut events, 0) + .expect("Emperor of Bones return must reach the replacement PayAmountChoice"); + + let WaitingFor::PayAmountChoice { + player, min, max, .. + } = runner.state().waiting_for.clone() + else { + panic!( + "the replacement must preserve its PayAmountChoice, got {}", + runner.waiting_for_kind() + ); + }; + assert_eq!(player, P0); + assert_eq!(min, 0); + assert!(max > 0); + + runner + .act(GameAction::SubmitPayAmount { amount: 0 }) + .expect("answer the replacement PayAmountChoice through GameRunner::act"); + + let state = runner.state(); + assert_eq!(state.objects[&peacekeeper].zone, Zone::Battlefield); + assert!(matches!( + state.waiting_for, + WaitingFor::Priority { player: P0 } + )); + assert!(state.stack.is_empty()); + assert_eq!( + state.delayed_triggers.len(), + 1, + "the Emperor delayed sacrifice rider must resume after the replacement choice" + ); +} diff --git a/crates/engine/tests/integration/issue_2425_fable_chapter_iii_transform.rs b/crates/engine/tests/integration/issue_2425_fable_chapter_iii_transform.rs index fc0dcb6324..e532f31595 100644 --- a/crates/engine/tests/integration/issue_2425_fable_chapter_iii_transform.rs +++ b/crates/engine/tests/integration/issue_2425_fable_chapter_iii_transform.rs @@ -50,6 +50,7 @@ fn reflection_back_face() -> BackFaceData { casting_restrictions: vec![], casting_options: vec![], layout_kind: None, + parse_warnings: vec![], } } @@ -80,6 +81,7 @@ fn etching_back_face() -> BackFaceData { casting_restrictions: vec![], casting_options: vec![], layout_kind: None, + parse_warnings: vec![], } } diff --git a/crates/engine/tests/integration/issue_4001_frolicking_familiar_adventure_instant.rs b/crates/engine/tests/integration/issue_4001_frolicking_familiar_adventure_instant.rs index 86ca2b2121..8b16553292 100644 --- a/crates/engine/tests/integration/issue_4001_frolicking_familiar_adventure_instant.rs +++ b/crates/engine/tests/integration/issue_4001_frolicking_familiar_adventure_instant.rs @@ -63,6 +63,7 @@ fn blow_off_steam_back_face() -> BackFaceData { casting_restrictions: Vec::new(), casting_options: Vec::new(), layout_kind: None, + parse_warnings: vec![], } } diff --git a/crates/engine/tests/integration/issue_5326_avatar_aang_transform.rs b/crates/engine/tests/integration/issue_5326_avatar_aang_transform.rs index 0c66d980fb..6aba669b6a 100644 --- a/crates/engine/tests/integration/issue_5326_avatar_aang_transform.rs +++ b/crates/engine/tests/integration/issue_5326_avatar_aang_transform.rs @@ -81,6 +81,7 @@ fn attach_aang_back_face(runner: &mut engine::game::scenario::GameRunner, aang: casting_restrictions: vec![], casting_options: vec![], layout_kind: None, + parse_warnings: vec![], }); } diff --git a/crates/engine/tests/integration/issue_6403_moonmist_mass_transform.rs b/crates/engine/tests/integration/issue_6403_moonmist_mass_transform.rs index 7c7083ccfb..6cdcd03dfe 100644 --- a/crates/engine/tests/integration/issue_6403_moonmist_mass_transform.rs +++ b/crates/engine/tests/integration/issue_6403_moonmist_mass_transform.rs @@ -48,6 +48,7 @@ fn attach_transform_back_face(runner: &mut GameRunner, object_id: ObjectId) { // CR 712.16: this is a transforming DFC, so the mass resolver may // transform it rather than applying CR 701.27c's no-op. layout_kind: Some(LayoutKind::Transform), + parse_warnings: vec![], }); } diff --git a/crates/engine/tests/integration/issue_691_sheoldred_saga_lore.rs b/crates/engine/tests/integration/issue_691_sheoldred_saga_lore.rs index 64894f8a23..544d68b4a6 100644 --- a/crates/engine/tests/integration/issue_691_sheoldred_saga_lore.rs +++ b/crates/engine/tests/integration/issue_691_sheoldred_saga_lore.rs @@ -49,6 +49,7 @@ fn true_scriptures_back_face() -> BackFaceData { casting_restrictions: vec![], casting_options: vec![], layout_kind: None, + parse_warnings: vec![], } } diff --git a/crates/engine/tests/integration/kamigawa_flip_cards.rs b/crates/engine/tests/integration/kamigawa_flip_cards.rs index 00c3340b18..ef16674be3 100644 --- a/crates/engine/tests/integration/kamigawa_flip_cards.rs +++ b/crates/engine/tests/integration/kamigawa_flip_cards.rs @@ -70,6 +70,7 @@ fn kenzo_alternative_face() -> BackFaceData { casting_restrictions: vec![], casting_options: vec![], layout_kind: Some(LayoutKind::Flip), + parse_warnings: vec![], } } @@ -129,6 +130,7 @@ fn tok_tok_alternative_face() -> BackFaceData { casting_restrictions: vec![], casting_options: vec![], layout_kind: Some(LayoutKind::Flip), + parse_warnings: vec![], } } diff --git a/crates/engine/tests/integration/main.rs b/crates/engine/tests/integration/main.rs index 92deedaf71..0cde5467e9 100644 --- a/crates/engine/tests/integration/main.rs +++ b/crates/engine/tests/integration/main.rs @@ -210,6 +210,7 @@ mod exile_dynamic_from_top; mod export_runtime_canaries; mod exquisite_blood_routing; mod extract_power_each_player_exile; +mod exuberant_wolfbear_base_pt_target; mod eyetwitch_learn_decline_lesson; mod fact_or_fiction_pile_separation; mod fantastic_four_bounded_loop; @@ -284,6 +285,7 @@ mod integration_landfall; mod interaction_contract; mod invoke_calamity_free_cast; mod ir_spell_node_readers; +mod ironsoul_enforcer_commander_attacks_alone; mod issue_1005_suffer_the_past; mod issue_1007_fractal_harness_attach; mod issue_1008_korvold_sacrifice_triggers; @@ -902,6 +904,7 @@ mod sensei_golden_tail_5950; mod sentinel_sliver_vigilance_grant; mod serpent_society_ward_poison_cost; mod serras_emissary_chosen_card_type_protection; +mod shorten_efficacy; mod sin_spiras_punishment_repeat; mod skullwinder_chosen_opponent; mod slaughter_the_strong_total_power_4380; diff --git a/crates/engine/tests/integration/room_door_lock_unlock.rs b/crates/engine/tests/integration/room_door_lock_unlock.rs index a818e1ea34..35d0328289 100644 --- a/crates/engine/tests/integration/room_door_lock_unlock.rs +++ b/crates/engine/tests/integration/room_door_lock_unlock.rs @@ -55,6 +55,7 @@ fn room_back_face() -> BackFaceData { casting_restrictions: Vec::new(), casting_options: Vec::new(), layout_kind: Some(LayoutKind::Split), + parse_warnings: vec![], } } diff --git a/crates/engine/tests/integration/rules/battle.rs b/crates/engine/tests/integration/rules/battle.rs index 1cb418f067..dbbf599964 100644 --- a/crates/engine/tests/integration/rules/battle.rs +++ b/crates/engine/tests/integration/rules/battle.rs @@ -104,6 +104,7 @@ fn siege_victory_cast_during_resolution_enters_transformed() { casting_restrictions: Vec::new(), casting_options: Vec::new(), layout_kind: None, + parse_warnings: vec![], }); } diff --git a/crates/engine/tests/integration/shorten_efficacy.rs b/crates/engine/tests/integration/shorten_efficacy.rs new file mode 100644 index 0000000000..005a18a9de --- /dev/null +++ b/crates/engine/tests/integration/shorten_efficacy.rs @@ -0,0 +1,3186 @@ +// engine-citation-gate: symbol anchors only +//! CR 732.2b/c stage 2 — EFFICACY of a polled seat's loop-shortcut response. +//! +//! CITATION FORM: rule NUMBER only. The number is itself the greppable heading — +//! `grep '^732.2c' docs/MagicCompRules.txt` resolves any citation below. Line +//! anchors are forbidden here (this file is enrolled in +//! `subsystem_citations_are_symbol_anchored`) because `docs/MagicCompRules.txt` +//! is gitignored and re-fetched per checkout, so a line anchor is pinned to +//! whichever rules revision the author happened to hold — the anchors this file +//! originally shipped already resolved to the wrong lines against the revision +//! fetched into the neighbouring checkout. +//! +//! `ai_support::smart_shortcut_response` shipped with a POSSIBILITY predicate +//! only: any meaningful priority action bought a `Shorten`, i.e. a real priority +//! window. That is right for a seat holding a Bolt and wrong for a seat holding +//! a fetchland — activating Terramorphic Expanse satisfies CR 732.2c's "must +//! make a different game choice" while changing nothing about the loop, so the +//! window is spent achieving nothing. +//! +//! Stage 2 is AI POLICY, not a rule: CR 732.2b grants an +//! unconditioned accept-or-shorten option and states no efficacy criterion. The +//! rows below pin the policy's two arms and, more importantly, pin the ONE +//! thing an over-broad version would destroy — that a seat holding real +//! interaction still gets its window. +//! +//! # Mutant discipline +//! +//! Two mutants are named per row, and every row states which one flips it: +//! * **DROP** — delete stage 2 from `smart_shortcut_response` (both arms), i.e. +//! restore the shipped one-stage predicate. +//! * **TRIVIALIZE** — make the stage-2 predicate constant. For arm (B) that is +//! `shortcut_efficacy::filter_is_actor_owned ≡ true` (everything looks +//! confined); for arm (A) it is deleting the `crowned_winner` guard. +//! +//! A row whose expected value equals the SHIPPED value cannot be flipped by +//! DROP — its discriminating power is entirely in TRIVIALIZE, and that is +//! stated on the row rather than papered over. + +use engine::analysis::decision_template::IterationCount; +use engine::analysis::loop_check::ShortcutResponse; +use engine::game::engine::apply; +use engine::game::scenario::{GameRunner, GameScenario}; +use engine::types::ability::{AbilityDefinition, AbilityKind, Effect, QuantityExpr, TargetFilter}; +use engine::types::actions::{GameAction, PrecastCopyShortcutResponse}; +use engine::types::card_type::{CoreType, Supertype}; +use engine::types::game_state::{GameState, LayersDirty, LoopDetectionMode, WaitingFor}; +use engine::types::identifiers::{CardId, ObjectId}; +use engine::types::mana::{ManaColor, ManaCost, ManaCostShard}; +use engine::types::phase::Phase; +use engine::types::player::PlayerId; +use engine::types::zones::Zone; + +const P0: PlayerId = PlayerId(0); +const P1: PlayerId = PlayerId(1); +const P2: PlayerId = PlayerId(2); +const P3: PlayerId = PlayerId(3); + +// --- Oracle-text constants, and what each one's provenance ACTUALLY is --- +// +// Two provenances ship here and they are deliberately not conflated, because +// "verbatim Oracle text" is a claim about a printing and only some of these +// make it. +// +// (1) SIBLING-FIXTURE PROVENANCE — the four loop-shape constants below are +// byte-identical copies of the shipped constants in +// `tests/integration/loop_shortcut.rs`, which does not present them as any +// card's Oracle text either. They exist to reproduce that file's mutual-drain +// loop, and copying them verbatim is what keeps the two files' loop shape +// identical. MEASURED against MTGJSON `AtomicCards.json` (`.data[*][0].text`): +// * `DRAIN_CLERIC` IS one printing's complete Oracle text (Epicure of Blood, +// Marauding Blight-Priest — 2 exact matches); +// * `BLOOD_SIPPER` matches NO card, not even as a substring; +// * `KICKOFF` / `TARGETED_KICKOFF` match no card's complete text; they are +// single-clause fragments (substrings of 53 and 3 cards respectively). +// So do NOT cite this block as card-derived: only `DRAIN_CLERIC` would +// survive that claim, and it is not why any of the four is here. +// +// (2) CARD PROVENANCE — `TERRAMORPHIC` and `DEATHRITE_SHAMAN` (below) ARE their +// named printing's complete Oracle text, verified byte-for-byte against +// MTGJSON. That matters for those two specifically: they are the rows' +// subject matter, and a paraphrase can take a different parser branch and go +// green while the real card stays broken. + +const DRAIN_CLERIC: &str = "Whenever you gain life, each opponent loses 1 life."; +const BLOOD_SIPPER: &str = "Whenever an opponent loses life, you gain 1 life."; +const KICKOFF: &str = "You gain 1 life."; +const TARGETED_KICKOFF: &str = "Target player gains 1 life."; + +/// Terramorphic Expanse, verbatim. Acceptance (a): a fetchland is the canonical +/// action that is legal, meaningful to stage 1, and totally confined. +const TERRAMORPHIC: &str = "{T}, Sacrifice this land: Search your library for a basic land card, \ + put it onto the battlefield tapped, then shuffle."; + +/// Deathrite Shaman, verbatim, all three abilities. Ability `[0]`'s cost is +/// `{T}` ALONE — no mana component — which is what lets the V1c fixture deny +/// `{B}`/`{G}` and still leave `[0]` legal. Its target is a land card in *a* +/// graveyard: the AST names no player, so ownership is UNPROVEN (CR 400.1 — +/// "Each player has their own library, hand, and graveyard"), which is exactly +/// why an `origin`-keyed confinement rule would wrongly call it self-contained. +const DEATHRITE_SHAMAN: &str = "{T}: Exile target land card from a graveyard. Add one mana of any \ + color.\n{B}, {T}: Exile target instant or sorcery card from a \ + graveyard. Each opponent loses 2 life.\n{G}, {T}: Exile target \ + creature card from a graveyard. You gain 2 life."; + +// --------------------------------------------------------------------------- +// Shared drive helpers. Deliberately local: `loop_shortcut.rs`'s equivalents +// are private to that module and it is not in this change's scope. +// --------------------------------------------------------------------------- + +/// Pass/answer beats until the state leaves `Priority`/`OrderTriggers`. +fn drive_collect(runner: &mut GameRunner, cap: usize) -> WaitingFor { + for _ in 0..cap { + match runner.state().waiting_for.clone() { + WaitingFor::Priority { .. } => { + if runner.act(GameAction::PassPriority).is_err() { + break; + } + } + WaitingFor::OrderTriggers { triggers, .. } => { + let order: Vec = (0..triggers.len()).collect(); + if runner + .act(GameAction::OrderTriggers { order }) + .or_else(|_| runner.act(GameAction::OrderTriggers { order: vec![] })) + .is_err() + { + break; + } + } + _ => break, + } + } + runner.state().waiting_for.clone() +} + +/// The exact action list `smart_shortcut_response` folds over — obtained by +/// CALLING production's recipe (`ai_support::shortcut_probe`), not by copying it. +/// A local copy would drift the moment production's recipe changed, and every +/// reach-guard in this file reads this list, so the guards would then be +/// measuring a different action set than the code under test. +fn probe_actions(state: &GameState, player: PlayerId) -> Vec { + engine::ai_support::shortcut_probe(state, player).1 +} + +/// Stage 1's verdict, evaluated on the PROBE state — which is the state +/// production evaluates it on. Evaluating it on the caller's +/// `RespondToShortcut` state instead silently drops +/// `has_meaningful_priority_action`'s sacrifice-for-mana rung, which is gated on +/// `waiting_for` being `Priority`. +fn stage_one_meaningful(state: &GameState, player: PlayerId) -> bool { + let (probe, actions) = engine::ai_support::shortcut_probe(state, player); + engine::ai_support::has_meaningful_priority_action(probe.state(), &actions) +} + +/// Which ability indices of `source` are actually enumerated at this window. +/// V1c's two reach-guards read this. +fn legal_ability_indices(state: &GameState, player: PlayerId, source: ObjectId) -> Vec { + let mut indices: Vec = probe_actions(state, player) + .iter() + .filter_map(|a| match a { + GameAction::ActivateAbility { + source_id, + ability_index, + } if *source_id == source => Some(*ability_index), + _ => None, + }) + .collect(); + indices.sort_unstable(); + indices +} + +/// The shipped `setup_3p_optional_cascade` shape (`loop_shortcut.rs`): P0 runs +/// a self-refilling mutual drain, P1's Mountain + Bolt make the loop OPTIONAL +/// so an offer is raised at all. `decorate` stages the seat under test. +fn optional_cascade(decorate: impl FnOnce(&mut GameScenario)) -> (GameRunner, ObjectId) { + let mut scenario = GameScenario::new_n_player(3, 7); + scenario.at_phase(Phase::PreCombatMain); + scenario.with_life(P0, 20); + scenario.with_life(P1, 20); + scenario.with_life(P2, 20); + scenario.add_creature_from_oracle(P0, "Test Drain Cleric", 2, 2, DRAIN_CLERIC); + scenario.add_creature_from_oracle(P0, "Test Blood Sipper", 2, 2, BLOOD_SIPPER); + scenario.add_basic_land(P1, ManaColor::Red); + scenario.add_bolt_to_hand(P1); + decorate(&mut scenario); + let kickoff = scenario + .add_spell_to_hand_from_oracle(P0, "Test Lifegain Kickoff", false, KICKOFF) + .id(); + let mut runner = scenario.build(); + runner.state_mut().loop_detection = LoopDetectionMode::Interactive; + (runner, kickoff) +} + +/// Cast the kick-off, drive to the offer, have P0 declare, then walk the APNAP +/// queue to `seat` by submitting manual Accepts for everyone ahead of it (never +/// the AI's answer — that would stop the queue). +fn respond_window_at(runner: &mut GameRunner, kickoff: ObjectId, seat: PlayerId) { + let _ = runner.cast(kickoff).resolve(); + let wf = drive_collect(runner, 500); + assert!( + matches!(wf, WaitingFor::LoopShortcut { .. }), + "reach-guard: the optional cascade must OFFER a shortcut, got {wf:?}" + ); + runner + .act(GameAction::DeclareShortcut { + count: IterationCount::UntilLethal, + template: None, + }) + .expect("the proposer declares"); + for _ in 0..8 { + match runner.state().waiting_for { + WaitingFor::RespondToShortcut { player, .. } if player == seat => return, + WaitingFor::RespondToShortcut { .. } => { + runner + .act(GameAction::RespondToShortcut { + response: ShortcutResponse::Accept, + }) + .expect("manual Accept advances the APNAP queue"); + } + _ => break, + } + } + panic!( + "reach-guard: {seat:?} must be polled; stopped at {:?}", + runner.state().waiting_for + ); +} + +// =========================================================================== +// V1b — ACCEPTANCE (a), Path A class: a fetchland no longer buys a window. +// =========================================================================== + +/// The shipped optional-cascade fixture plus exactly ONE card: a Terramorphic +/// Expanse on the polled seat's battlefield. Stage 1 still says "you have a +/// meaningful action" — a non-mana activated ability always does — and stage 2 +/// answers the question stage 1 cannot: the action reaches nothing but its own +/// controller's library, so the window would change nothing. +/// +/// MUTANTS — both flip the `Accept` assertion: +/// * DROP ⇒ `Shorten { at_iteration: 0 }` (this IS the shipped behaviour, which +/// is the defect). +/// * TRIVIALIZE arm (B) (`any_action_may_interfere ≡ true`) ⇒ `Shorten`. +/// +/// REACH-GUARDS (both are assertions): the fetchland really is enumerated, and +/// stage 1 really does return `true`. Without them an `Accept` here would be +/// indistinguishable from the empty-board stage-1 path — the vacuity that a +/// naive version of this row would ship. +#[test] +fn v1b_a_confined_fetchland_accepts_instead_of_buying_a_vacuous_window() { + let mut terramorphic = ObjectId(0); + let (mut runner, kickoff) = optional_cascade(|s| { + terramorphic = s + .add_land_from_oracle(P2, "Terramorphic Expanse", TERRAMORPHIC) + .id(); + }); + respond_window_at(&mut runner, kickoff, P2); + + let actions = probe_actions(runner.state(), P2); + assert!( + actions.contains(&GameAction::ActivateAbility { + source_id: terramorphic, + ability_index: 0, + }), + "REACH-GUARD 1: the fetchland's ability must be enumerated, otherwise this row \ + degenerates to the empty-board stage-1 path; got {actions:?}" + ); + assert!( + stage_one_meaningful(runner.state(), P2), + "REACH-GUARD 2: stage 1 (POSSIBILITY, untouched by this change) must still return \ + true — an Accept produced by stage 1 would prove nothing about stage 2" + ); + + assert_eq!( + engine::ai_support::smart_shortcut_response(runner.state(), P2), + ShortcutResponse::Accept, + "a seat whose ONLY action is a self-contained fetch has no efficacious response; \ + spending a real priority window on it changes nothing (CR 732.2c is satisfied by \ + any different choice, which is precisely why it grants no efficacy)" + ); +} + +// =========================================================================== +// V1c — B1 REGRESSION LOCK. The graveyard-hate seat still Shortens. +// =========================================================================== + +/// The one row whose sole job is pinning the owner axis. A graveyard is a +/// PER-PLAYER zone (CR 400.1) and `Zone` carries no +/// player field, so a rule keyed on `ChangeZone.origin` cannot tell "exile a +/// land card from MY graveyard" from "…from YOURS". Deathrite Shaman `[0]` +/// exiles a land card from P0's graveyard — a real interaction with another +/// player's resources — and must keep its window. +/// +/// This row's expected value (`Shorten`) IS the shipped value, so **DROP cannot +/// flip it**. Its whole discriminating power is the TRIVIALIZE arm: +/// `filter_is_actor_owned ≡ true` makes DRS `[0]` fold to `OwnResourcesOnly` +/// and the response becomes `Accept` — the row FLIPS. +/// +/// That flip only exists if the fixture leaves ability `[0]` and ONLY `[0]` +/// legal, so both constraints ship as assertions: +/// * REACH-GUARD 1 — without a land card in a graveyard, `[0]` is not +/// enumerated at all and the action list collapses to `["PassPriority"]`; +/// stage 1 returns false and the row would pass through the wrong path. +/// * REACH-GUARD 2 — with `{B}`/`{G}` available and a matching graveyard card, +/// `[1]`/`[2]` become legal. Their `LoseLife`/`GainLife` sub-effects classify +/// `MayInterfere` even under the mutant, so `any_action_may_interfere`'s +/// `.any()` absorbs the mutation and the row passes VACUOUSLY. +/// +/// The fixture denies `{B}`/`{G}` by construction (P2 controls no lands) and +/// stages no instant/sorcery/creature card in any graveyard, so `[1]` and `[2]` +/// are each blocked on two independent axes. +#[test] +fn v1c_graveyard_hate_across_a_per_player_zone_keeps_its_window() { + let mut shaman = ObjectId(0); + let (mut runner, kickoff) = optional_cascade(|s| { + // CR 302.6 (the summoning-sickness rule): `add_creature_from_oracle` + // stages a pre-existing battlefield creature, so the `{T}` cost is + // payable. + shaman = s + .add_creature_from_oracle(P2, "Deathrite Shaman", 1, 2, DEATHRITE_SHAMAN) + .id(); + // The land card sits in P0's graveyard — the ability reaches ACROSS a + // per-player zone that the AST does not player-qualify. That crossing + // is the whole of the defect this row locks. + s.add_land_to_graveyard(P0, "Test Graveyard Land"); + }); + respond_window_at(&mut runner, kickoff, P2); + + let indices = legal_ability_indices(runner.state(), P2, shaman); + assert!( + indices.contains(&0), + "REACH-GUARD 1: without a land card in a graveyard the Shaman's [0] is not enumerated \ + and this row degenerates to the stage-1 empty-action path; got {indices:?}" + ); + assert_eq!( + indices, + vec![0], + "REACH-GUARD 2: [1]/[2] must stay illegal. They classify MayInterfere even under the \ + TRIVIALIZE mutant, so leaving one legal lets .any() absorb the mutation and this row \ + passes vacuously; got {indices:?}" + ); + + assert_eq!( + engine::ai_support::smart_shortcut_response(runner.state(), P2), + ShortcutResponse::Shorten { at_iteration: 0 }, + "exiling a land card out of ANOTHER player's graveyard is real interaction — the \ + confinement predicate must require PROVEN actor ownership, not merely a zone name" + ); +} + +// =========================================================================== +// V2 / V3 — ACCEPTANCE (b) and the matched pair. +// =========================================================================== + +/// V3, both arms in one row, because neither arm alone is the discriminator. +/// The two boards are identical but for P2's holdings: +/// * `{}` ⇒ Accept, reached through stage 1 (nothing to do); +/// * `{Mountain, Lightning Bolt}` ⇒ Shorten, reached through stage 2. +/// +/// The pass ⇒ grant / respond ⇒ no-grant pair is what proves stage 2 did not +/// over-generalize into "always Accept". Sibling coverage for Wrath of God, +/// Naturalize, Divination and Path to Exile is at classifier granularity in +/// `ai_support::shortcut_efficacy`'s unit table (they are sorceries/instants +/// with no legal target on this board, so a runtime row would assert on +/// castability rather than on efficacy). +/// +/// MUTANTS: TRIVIALIZE arm (B) (`any_action_may_interfere ≡ false`, or +/// `filter_is_actor_owned ≡ true` — Bolt's `DealDamage` reaches neither, so it +/// is the whole-predicate constant that bites) flips the Bolt arm to `Accept`. +/// DROP leaves both arms at their shipped values and flips neither; that is +/// stated rather than claimed otherwise. +#[test] +fn v3_matched_pair_empty_seat_accepts_and_bolt_seat_still_shortens() { + // Arm 1 — nothing at all. + let (mut bare, bare_kickoff) = optional_cascade(|_| {}); + respond_window_at(&mut bare, bare_kickoff, P2); + let bare_actions = probe_actions(bare.state(), P2); + assert!( + !stage_one_meaningful(bare.state(), P2), + "reach-guard: this arm must resolve at STAGE 1, so it stays a control for the stage-2 \ + arm below; got {bare_actions:?}" + ); + assert_eq!( + engine::ai_support::smart_shortcut_response(bare.state(), P2), + ShortcutResponse::Accept, + "no meaningful action ⇒ Accept, unchanged from the shipped predicate" + ); + + // Arm 2 — the SAME board plus a Mountain and a Bolt. + let (mut armed, armed_kickoff) = optional_cascade(|s| { + s.add_basic_land(P2, ManaColor::Red); + s.add_bolt_to_hand(P2); + }); + respond_window_at(&mut armed, armed_kickoff, P2); + let armed_actions = probe_actions(armed.state(), P2); + assert!( + armed_actions + .iter() + .any(|a| matches!(a, GameAction::CastSpell { .. })), + "reach-guard: the Bolt must actually be castable, otherwise this arm tests the empty \ + board twice; got {armed_actions:?}" + ); + assert_eq!( + engine::ai_support::smart_shortcut_response(armed.state(), P2), + ShortcutResponse::Shorten { at_iteration: 0 }, + "ACCEPTANCE (b): a seat holding real interaction must still get its priority window — \ + this is the assertion any over-broad confinement rule destroys first" + ); +} + +// =========================================================================== +// V4 / V6 / V7 — arm (A): the crowned seat, keyed on `predicted_winner`. +// =========================================================================== + +/// CR 732.2a lets the player with priority propose a +/// shortcut whose predictable result crowns SOMEONE ELSE. This fixture is the +/// shipped `interactive_offer_separates_priority_proposer_from_predicted_winner` +/// shape: P1 proposes, P0 is the measured winner, and P1 (the proposer) is +/// excluded from the response queue, so P0 is polled. +/// +/// P0 also holds a Mountain and a Bolt — the reach-guard the measurement proved +/// load-bearing. WITHOUT them P0 has no meaningful action and Accepts via +/// stage 1, making the row vacuous; WITH them the shipped predicate returns +/// `Shorten`, i.e. the crowned player shortens its own guaranteed win. +/// +/// Three claims ride this one board: +/// * **V4** — arm (A) fires: the crowned seat Accepts. +/// * **V6** — it is keyed on `predicted_winner`, never `proposer`. The row +/// asserts `proposer != predicted_winner` and `polled == predicted_winner`, +/// so a `proposer`-keyed implementation (which passes every other row) fails +/// exactly here. +/// * **V7** — read order. Arm (A) reads the proposal off the ORIGINAL state; +/// `smart_shortcut_response` overwrites its probe clone's `waiting_for` with +/// `Priority` before enumerating. Moving that read after the clone makes +/// `crowned_winner` unconditionally `None` and this row FAILS — it is the +/// only row that can detect the mis-ordering. +/// +/// MUTANTS — both flip the `Accept` assertion: DROP ⇒ `Shorten`; TRIVIALIZE +/// arm (A) (delete the `crowned_winner` guard) ⇒ `Shorten` via arm (B), because +/// the Bolt is genuine interference. +#[test] +fn v4_the_crowned_seat_accepts_its_own_predicted_win() { + let mut scenario = GameScenario::new_n_player(2, 7); + scenario.at_phase(Phase::PreCombatMain); + scenario.with_life(P0, 20); + scenario.with_life(P1, 20); + scenario.add_creature_from_oracle(P0, "Test Drain Cleric", 2, 2, DRAIN_CLERIC); + scenario.add_creature_from_oracle(P0, "Test Blood Sipper", 2, 2, BLOOD_SIPPER); + scenario.add_basic_land(P1, ManaColor::Red); + scenario.add_bolt_to_hand(P1); + // The reach-guard: P0 must hold a meaningful action or stage 1 answers first. + scenario.add_basic_land(P0, ManaColor::Red); + scenario.add_bolt_to_hand(P0); + let kickoff = scenario + .add_spell_to_hand_from_oracle(P1, "P0 Lifegain Kickoff", false, TARGETED_KICKOFF) + .id(); + let mut runner = scenario.build(); + runner.state_mut().loop_detection = LoopDetectionMode::Interactive; + runner.state_mut().active_player = P1; + runner.state_mut().priority_player = P1; + runner.state_mut().waiting_for = WaitingFor::Priority { player: P1 }; + + let _ = runner.cast(kickoff).target_player(P0).resolve(); + let wf = drive_collect(&mut runner, 500); + let WaitingFor::LoopShortcut { + proposer, + predicted_winner, + .. + } = wf + else { + panic!("reach-guard: P1's priority window must receive an offer, got {wf:?}"); + }; + assert_eq!( + proposer, P1, + "CR 732.2a routes the offer to the priority holder" + ); + assert_eq!( + predicted_winner, + Some(P0), + "reach-guard: the two authorities must actually DIFFER, or the winner-keyed and \ + proposer-keyed implementations are indistinguishable here" + ); + + runner + .act(GameAction::DeclareShortcut { + count: IterationCount::UntilLethal, + template: None, + }) + .expect("P1 declares"); + let WaitingFor::RespondToShortcut { + player, + ref proposal, + .. + } = runner.state().waiting_for + else { + panic!( + "reach-guard: a response window must open, got {:?}", + runner.state().waiting_for + ); + }; + assert_eq!( + player, P0, + "the proposer is excluded from its own response queue, so the crowned seat is polled" + ); + assert_ne!( + proposal.proposer, + proposal + .predicted_winner + .expect("this offer names a winner"), + "V6: the multi-authority premise — a proposer-keyed rule would read P1 here" + ); + + let actions = probe_actions(runner.state(), P0); + assert!( + stage_one_meaningful(runner.state(), P0), + "REACH-GUARD: without a meaningful action P0 would Accept via stage 1 and this row \ + would be vacuous; got {actions:?}" + ); + + assert_eq!( + engine::ai_support::smart_shortcut_response(runner.state(), P0), + ShortcutResponse::Accept, + "arm (A): the offer's predicted result already crowns this seat. CR 732.2c grants a \ + shortening player nothing but the obligation to choose differently, so shortening \ + here moves the game away from a win it already holds" + ); +} + +// =========================================================================== +// V1 / V5 — REAL 4-player board, loaded through the production restore +// chokepoint and driven through the public `apply()` boundary. +// =========================================================================== + +/// Inflate a committed dump fixture. +fn gunzip_dump(gz: &[u8]) -> String { + use std::io::Read; + let mut json = String::new(); + flate2::read::GzDecoder::new(gz) + .read_to_string(&mut json) + .expect("fixture .json.gz must inflate to UTF-8 JSON"); + json +} + +/// Decode AS `PersistedGameState` — the production chokepoint the server's +/// `from_persisted` and WASM's `decode_restored_game_state` both funnel +/// through — rather than decoding a bare `GameState`. +fn restore_dump(json: &str) -> GameState { + let envelope: serde_json::Value = + serde_json::from_str(json).expect("dump envelope parses as JSON"); + serde_json::from_value::( + envelope["gameState"].clone(), + ) + .expect("gameState deserializes through the production decoder") + .into_game_state() +} + +/// The LIVE-PATH board: the real 4-player Dina / Bloodthirsty Conqueror drain +/// on which the defect actually occurs, because seat P2 controls a Terramorphic +/// Expanse. +/// +/// Derived from the read-only pristine archive, and the derivation is the +/// artifact's provenance rather than a claim about it: +/// `unzip -p combofb-dumps-pristine/dina-conqueror-offers-no-ff.zip | +/// jq -c '{gameState}' | gzip -9 -n` +/// → 844846 bytes, sha256 +/// `9843d5165cbbf7dd7bca4171c7888c190b7eba7e52a2ed095b44ff76fadd7886`. +/// `gzip -n` is a no-op from a pipe but load-bearing from a file (it strips the +/// stored name and mtime), so KEEP it: a re-derivation that stages the 21 MB +/// dump through an intermediate file — the natural thing to do at that size — +/// otherwise misses the digest and presents as a corrupt artifact rather than +/// as convention drift. +fn live_path_board() -> GameState { + restore_dump(&gunzip_dump(include_bytes!( + "../fixtures/dina_noff_turn5_4p.json.gz" + ))) +} + +/// The QUIET board: the same matchup captured at a beat where NO seat holds any +/// meaningful priority action. Retained only as a negative control — see +/// `v1_control_*` for why it has no discriminating power for acceptance (a). +fn quiet_board() -> GameState { + restore_dump(&gunzip_dump(include_bytes!( + "../fixtures/dina_conqueror_4p.json.gz" + ))) +} + +fn dump_driver_forbids(a: &GameAction) -> bool { + matches!(a, GameAction::Concede { .. } | GameAction::Debug(_)) +} + +fn dump_beat_actor(state: &GameState) -> Option<(PlayerId, Vec)> { + if let Some(p) = state.waiting_for.acting_player() { + let (actions, _costs, _grouped) = engine::ai_support::legal_actions_for_viewer(state, p); + if !actions.is_empty() { + return Some((p, actions)); + } + } + for p in state.players.iter().map(|p| p.id) { + let (actions, _costs, _grouped) = engine::ai_support::legal_actions_for_viewer(state, p); + if !actions.is_empty() { + return Some((p, actions)); + } + } + None +} + +/// One beat of the drain-drive policy: at `Priority` ALWAYS pass (the mandatory +/// triggers re-trigger — that IS the loop), answer every other prompt. +fn dump_drive_one_beat(state: &mut GameState) -> Result<(), String> { + let Some((who, actions)) = dump_beat_actor(state) else { + return Err(format!("no legal actor at {:?}", state.waiting_for)); + }; + let chosen = if matches!(state.waiting_for, WaitingFor::Priority { .. }) { + actions + .iter() + .find(|a| matches!(a, GameAction::PassPriority)) + .cloned() + } else { + actions + .iter() + .find(|a| !matches!(a, GameAction::PassPriority) && !dump_driver_forbids(a)) + .or_else(|| actions.iter().find(|a| !dump_driver_forbids(a))) + .cloned() + }; + let Some(action) = chosen else { + return Err(format!("empty action list at {:?}", state.waiting_for)); + }; + apply(state, who, action.clone()) + .map(|_| ()) + .map_err(|e| format!("apply err ({action:?}): {e:?}")) +} + +/// Drive real beats until the board mints a bounded offer. +fn drive_to_offer(state: &mut GameState, cap: usize) -> Option { + for beat in 0..cap { + if matches!(state.waiting_for, WaitingFor::LoopShortcut { .. }) { + return Some(beat); + } + if dump_drive_one_beat(state).is_err() { + return None; + } + } + None +} + +// NOTE: no `give_fetchland` staging helper. The live-path test drives the seat's +// OWN Terramorphic Expanse out of the restored dump (`ObjectId(203)`), so there is +// nothing to inject; a staging helper here would have made the live test synthetic +// again. `give_bolt` below survives because the positive control needs an +// interactive card the recorded board does not contain. + +/// Stage a castable Lightning Bolt in `player`'s hand, mirroring +/// `GameScenario::add_bolt_to_hand` (same `Effect::DealDamage` ability, same +/// absence of a printed mana cost) so the positive control below is the same +/// interaction the shipped fixtures use. +fn give_bolt(state: &mut GameState, player: PlayerId) -> ObjectId { + give_bolt_with_cost(state, player, ManaCost::zero()) +} + +/// `give_bolt` with a PRINTED cost, so a row can stage an interaction the seat +/// cannot yet afford. `GameObject::mana_cost` is the field the castability probe +/// reads, and `ManaCost`'s `Default` is `zero()` (`GameObject::new` seeds both +/// cost fields from it), so the free-Bolt caller above is byte-unchanged. +/// +/// BOTH fields are assigned, but the LIVE one is what carries this helper — +/// `base_mana_cost` is NOT load-bearing for the objects staged here, and saying +/// otherwise would be a justification the next reader trusts. +/// +/// READ FROM SOURCE (three call sites, not a runtime probe — the evidence grade +/// is stated because overclaiming it is the very habit this comment replaces). +/// `game::layers`' base→live reseed does run +/// `mana_cost = base_mana_cost.clone()` (`seed_live_characteristics_from_base`), +/// and every consumer here does reach the object through +/// `ai_support::shortcut_probe`, which flushes layers — but the full pass +/// applies that reseed (via `reset_recipient_to_base`) only over +/// `battlefield_phased_in_ids()`, and the hand branch of the same pass resets +/// `keywords` alone. `layers::layer_pass_materializes_keywords`' doc is the +/// in-repo authority for that split ("Battlefield — resets the full +/// characteristic set" vs "Hand — keywords-only reset"); the incremental arm +/// resets only battlefield entrants and their hosts, so it cannot reach a hand +/// object either. This object is staged to `Zone::Hand`, so no pass reseeds its +/// `mana_cost`. `GameObject`'s +/// `sync_missing_base_characteristics` — which the hand branch DOES call — +/// would in fact back-fill `base_mana_cost` from the live field, the opposite +/// direction. +/// +/// `base_mana_cost` is set for symmetry: it keeps the two fields from +/// disagreeing on a freshly minted object, and it keeps the helper correct if +/// the hardcoded `Zone::Hand` below ever becomes the battlefield, where the +/// reseed WOULD restore the default (free) cost over the printed one and +/// silently leave an "otherwise-unaffordable" premise measuring nothing. +fn give_bolt_with_cost(state: &mut GameState, player: PlayerId, cost: ManaCost) -> ObjectId { + let card_id = CardId(state.next_object_id); + let id = engine::game::zones::create_object( + state, + card_id, + player, + "Lightning Bolt".to_string(), + Zone::Hand, + ); + let ability = AbilityDefinition::new( + AbilityKind::Spell, + Effect::DealDamage { + amount: QuantityExpr::Fixed { value: 3 }, + target: TargetFilter::Any, + damage_source: None, + excess: None, + }, + ); + let obj = state.objects.get_mut(&id).expect("just created"); + obj.card_types + .core_types + .push(engine::types::card_type::CoreType::Instant); + obj.base_card_types = obj.card_types.clone(); + obj.base_mana_cost = cost.clone(); + obj.mana_cost = cost; + obj.abilities = std::sync::Arc::new(vec![ability.clone()]); + obj.base_abilities = std::sync::Arc::new(vec![ability]); + state.layers_dirty = LayersDirty::full(); + id +} + +/// Declare the offer this board minted, then poll `seat` by walking the APNAP +/// queue with MANUAL Accepts (never the AI's answer, which would stop the +/// queue). Returns the state parked at `seat`'s response window. +fn declare_and_poll(state: &GameState, seat: PlayerId) -> GameState { + let WaitingFor::LoopShortcut { + proposer, + ref schema, + .. + } = state.waiting_for + else { + panic!( + "declare_and_poll expects a LoopShortcut window, got {:?}", + state.waiting_for + ); + }; + let mut s = state.clone(); + apply( + &mut s, + proposer, + GameAction::DeclareShortcut { + count: schema.iteration_count.clone(), + template: None, + }, + ) + .expect("the proposer declares its own offer"); + for _ in 0..8 { + match s.waiting_for { + WaitingFor::RespondToShortcut { player, .. } if player == seat => return s, + WaitingFor::RespondToShortcut { player, .. } => { + apply( + &mut s, + player, + GameAction::RespondToShortcut { + response: ShortcutResponse::Accept, + }, + ) + .expect("manual Accept advances the APNAP queue"); + } + _ => break, + } + } + panic!( + "reach-guard: {seat:?} must be polled; stopped at {:?}", + s.waiting_for + ); +} + +/// The non-`PassPriority` actions available to `seat`, rendered with the source +/// object's name / zone / controller so a reach-guard failure names the board +/// rather than an opaque id. +fn non_pass_actions(state: &GameState, seat: PlayerId) -> Vec { + probe_actions(state, seat) + .iter() + .filter(|a| !matches!(a, GameAction::PassPriority)) + .map(|a| match a { + GameAction::ActivateAbility { + source_id, + ability_index, + } => { + let o = state.objects.get(source_id); + format!( + "ActivateAbility({source_id:?} {:?} #{ability_index} zone={:?} controller={:?})", + o.map(|o| o.name.clone()), + o.map(|o| o.zone), + o.map(|o| o.controller) + ) + } + other => format!("{other:?}"), + }) + .collect() +} + +// =========================================================================== +// V1 / V5 — ACCEPTANCE (a) on the REAL 4-player board, LIVE PATH. +// =========================================================================== + +/// **The acceptance row.** A real 4-player Dina / Bloodthirsty Conqueror drain, +/// restored through the production chokepoint +/// (`PersistedGameState::into_game_state()`, the same path the server's +/// `from_persisted` and WASM's `decode_restored_game_state` funnel through) and +/// driven beat by beat through the public `apply()` boundary. No `GameScenario` +/// anywhere on this row: a synthetic board going green while the live 4p case +/// failed is a documented failure mode in this lane. +/// +/// The defect, on this exact board: seat P2 controls **`ObjectId(203)`, +/// "Terramorphic Expanse"**, on the battlefield. A non-mana activated ability is +/// unconditionally "meaningful" to stage 1, so the shipped one-stage predicate +/// answered `Shorten { at_iteration: 0 }` — handing P2 a real priority window +/// whose only content is cracking its own fetchland, which cannot touch the +/// drain. Stage 2 answers `Accept`. +/// +/// **V5 — bounded offers are NOT exempt.** MEASURED on this board: the offer +/// mints at beat 21 carrying `predicted_winner: None` and +/// `IterationCount::Fixed(25)`. It is the BOUNDED class, not the `UntilLethal` +/// class the synthetic rows use, and `predicted_winner: None` additionally +/// proves arm (A) cannot be what produces the `Accept` below — only arm (B) +/// can. Re-introducing an `UntilLethal`-only gate makes this row return +/// `Shorten` and fail. +/// +/// **Why the flip set is exactly {P2}, asserted rather than asserted-about.** +/// P1 and P3 are polled on the same board and hold nothing, so they answer at +/// stage 1 and are unaffected. That is the sibling control: the change is +/// surgical, not a blanket flip to `Accept`. +/// +/// MUTANTS — the `Accept` assertion flips under both: +/// * **DROP** (delete stage 2) ⇒ `Shorten { at_iteration: 0 }`, which is the +/// shipped behaviour and therefore the defect itself; +/// * **TRIVIALIZE** (`any_action_may_interfere ≡ true`) ⇒ `Shorten`. +/// +/// The opposite direction is `v1_positive_control_*` below, on this same board. +#[test] +fn v1_live_path_fetchland_seat_accepts_on_the_real_4p_board() { + let mut board = live_path_board(); + assert!( + !matches!(board.waiting_for, WaitingFor::LoopShortcut { .. }), + "reach-guard: the dump must not ship AT an offer — the offer is this drive's product, \ + not its input; got {:?}", + board.waiting_for + ); + let beat = drive_to_offer(&mut board, 400).expect( + "CR 732.2a: the offer must FIRE on this real 4p drain. A failure here is the offer \ + never being raised, not a fixture accident", + ); + let WaitingFor::LoopShortcut { + predicted_winner, + ref schema, + .. + } = board.waiting_for + else { + unreachable!("drive_to_offer only returns at a LoopShortcut window"); + }; + + // ── V5's premise, read off the live offer rather than assumed ── + assert_eq!( + predicted_winner, None, + "V5: the BOUNDED class mints no crown — so arm (A) is structurally unable to produce \ + the Accept below, and only arm (B) can (offer beat {beat})" + ); + assert_eq!( + schema.iteration_count, + IterationCount::Fixed(25), + "V5: a FINITE count is the point — stage 2 takes the identical rule for it and for \ + the UntilLethal class" + ); + + // ── the row: P2, whose only action is its own fetchland ── + let polled = declare_and_poll(&board, P2); + let non_pass = non_pass_actions(&polled, P2); + + assert_eq!( + non_pass.len(), + 1, + "REACH-GUARD: P2 must hold EXACTLY ONE non-pass action. Two would let the fold's \ + .any() reach Shorten through the other one and this row would pass for the wrong \ + reason; zero would make it the stage-1 path; got {non_pass:?}" + ); + assert!( + non_pass[0].contains("Terramorphic Expanse") + && non_pass[0].contains("zone=Some(Battlefield)") + && non_pass[0].contains("controller=Some(PlayerId(2))"), + "REACH-GUARD: that one action must be P2's OWN battlefield fetchland — the object the \ + diagnosis pinned; got {non_pass:?}" + ); + + // NON-VACUITY PIN. The guards above read the FLAT list, which cannot contain + // a BATTLEFIELD mana activation: `candidates.rs` excludes it at generation + // (`!is_mana_ability(&ability_def)`), and a land's `TapLandForMana` is + // additionally dropped by `flat_priority_actions_with_probe`'s + // `GameAction::is_mana_ability` filter. (It CAN contain a hand- or + // graveyard-zone mana activation, which has its own candidate loop and is a + // `GameAction::ActivateAbility` — so the filter never sees it. That class is + // not on this board, and what would catch it is the `non_pass.len() == 1` + // REACH-GUARD above, NOT the assertion below: such an activation is already + // IN the flat list, so it shows up as a second non-pass action, while + // `stage_two_action_set` only APPENDS `meaningful_sacrifice_mana_actions` — + // a non-sacrifice one adds nothing and `stage_two == flat` still holds.) + // The set stage 2 actually folds over is WIDER still — `stage_two_action_set` + // re-admits sacrifice-for-mana activations — so without this the flagship is + // blind to exactly the class that would vacuate the feature: a seat that + // acquired a Lotus-Petal-shaped permanent during the drive would silently + // start Shortening and this row would flip. + let (probe, flat) = engine::ai_support::shortcut_probe(&polled, P2); + let stage_two = engine::ai_support::stage_two_action_set(probe.state(), &flat); + assert_eq!( + stage_two, flat, + "NON-VACUITY: P2 must own NO mana-producing action on this board, so its Accept is \ + produced by the fetchland's confinement and NOT by the absence of a widening. If this \ + fails, the flagship Accept is no longer measuring what it claims — re-derive the row, \ + do NOT relax the assertion" + ); + + assert!( + stage_one_meaningful(&polled, P2), + "REACH-GUARD: stage 1 (POSSIBILITY, untouched here) must still return true. An Accept \ + produced by stage 1 would prove nothing about stage 2 — this is the assertion that \ + makes the row non-vacuous" + ); + + assert_eq!( + engine::ai_support::smart_shortcut_response(&polled, P2), + ShortcutResponse::Accept, + "ACCEPTANCE (a), LIVE PATH: cracking its own fetchland cannot touch the drain, so P2 \ + must not buy a priority window with it. CR 732.2c is satisfied by ANY different \ + choice, which is exactly why satisfying it carries no efficacy" + ); + + // ── sibling control: the flip set is exactly {P2} ── + for seat in [P1, P3] { + let other = declare_and_poll(&board, seat); + assert!( + !stage_one_meaningful(&other, seat), + "sibling control: {seat:?} holds nothing on this board, so it answers at stage 1 \ + and stage 2 never runs for it; got {:?}", + non_pass_actions(&other, seat) + ); + assert_eq!( + engine::ai_support::smart_shortcut_response(&other, seat), + ShortcutResponse::Accept, + "sibling control: {seat:?} is unchanged by this fix — the flip set is exactly {{P2}}" + ); + } +} + +/// The positive control for the row above, on the SAME real board: give P2 a +/// castable Lightning Bolt and it must Shorten. +/// +/// This is what makes `v1_live_path_*`'s `Accept` attributable. The same +/// instrument, on the same restored 4p board, at the same offer, returns BOTH +/// values — so the `Accept` is caused by the fetchland's confinement and not by +/// anything about the board, the beat, or the offer class. Without this row a +/// classifier that answered `Accept` unconditionally would pass. +/// +/// MUTANT: `any_action_may_interfere ≡ false` ⇒ `Accept` — this row flips. +/// +/// This row's expected value (`Shorten`) IS the shipped value, so **DROP cannot +/// flip it**. Its whole discriminating power is the TRIVIALIZE arm named above. +#[test] +fn v1_positive_control_interactive_seat_still_shortens_on_the_real_4p_board() { + let mut board = live_path_board(); + drive_to_offer(&mut board, 400).expect("the offer must fire"); + let bolt = give_bolt(&mut board, P2); + + let polled = declare_and_poll(&board, P2); + let actions = probe_actions(&polled, P2); + assert!( + actions + .iter() + .any(|a| matches!(a, GameAction::CastSpell { object_id, .. } if *object_id == bolt)), + "REACH-GUARD: the Bolt must actually be castable here, or this control cannot fire; \ + got {:?}", + non_pass_actions(&polled, P2) + ); + + assert_eq!( + engine::ai_support::smart_shortcut_response(&polled, P2), + ShortcutResponse::Shorten { at_iteration: 0 }, + "ACCEPTANCE (b), LIVE PATH: a seat holding real interaction must still get its window. \ + This is the assertion any over-broad confinement rule destroys first" + ); +} + +/// NEGATIVE CONTROL — and its limits are the point. +/// +/// The previously-tracked `dina_conqueror_4p` board is the same matchup at a +/// beat where NO seat holds any meaningful priority action. MEASURED: the offer +/// mints at beat 19 with `predicted_winner: None`, `IterationCount::Fixed(30)`, +/// and all three polled seats (P1, P2, P3) enumerate exactly +/// `["PassPriority"]` — length one, NOT empty; it is +/// `has_meaningful_priority_action` returning false that produces the Accept, +/// not an empty action vector. +/// +/// **This board therefore has ZERO discriminating power for acceptance (a), and +/// it is retained only for what it CAN show.** The defect needs a seat holding +/// a meaningful-but-vacuous action; a board with no such seat cannot exhibit it, +/// so this row passes identically with and without stage 2. Do not promote it +/// to an acceptance row, and do not read its green as evidence about the fix: +/// what it pins is the one-way property that stage 2 must not make a quiet +/// board start Shortening. +#[test] +fn v1_control_quiet_board_is_unchanged_and_cannot_discriminate() { + let mut board = quiet_board(); + let beat = drive_to_offer(&mut board, 400).expect("the quiet board still mints an offer"); + let WaitingFor::LoopShortcut { + predicted_winner, + ref schema, + .. + } = board.waiting_for + else { + unreachable!() + }; + assert_eq!(predicted_winner, None, "bounded class (offer beat {beat})"); + assert_eq!(schema.iteration_count, IterationCount::Fixed(30)); + + for seat in [P1, P2, P3] { + let polled = declare_and_poll(&board, seat); + let actions = probe_actions(&polled, seat); + assert_eq!( + actions, + vec![GameAction::PassPriority], + "the premise of this control: {seat:?} enumerates exactly one action, and it is a \ + pass. If this ever fails the board is no longer quiet and the row's `cannot \ + discriminate` claim needs re-deriving" + ); + assert!( + !stage_one_meaningful(&polled, seat), + "and it is stage 1, not an empty action list, that answers" + ); + assert_eq!( + engine::ai_support::smart_shortcut_response(&polled, seat), + ShortcutResponse::Accept, + "no-regress: stage 2 must not make a quiet seat start Shortening" + ); + } +} + +// =========================================================================== +// V8 — the SECOND window this authority answers: the pre-cast copy route. +// =========================================================================== + +const PRECAST_EPOCH: u64 = 7; +const PRECAST_BREAKPOINT: u64 = 99; + +/// Re-park an already-polled state at the PRE-CAST responder window, board +/// untouched. +/// +/// Hand-built from the engine's own constructor shape +/// (`game::precast_copy_shortcut::responder_wait`), exactly as the shipped +/// `precast_copy_shortcut.rs` fixture `precast_shortcut_response_state` does. +/// Sound HERE specifically: `smart_shortcut_response` reads `waiting_for` for +/// one thing only (the crown — and this variant carries no crown to read) and +/// then re-parks its own probe clone at `Priority`, so the efficacy answer is a +/// function of the BOARD. Driving a genuine pre-cast copy route would supply a +/// different board, which is the one variable this row must hold fixed against +/// `v1b`/`v3` above. +fn as_precast_window(state: &GameState, seat: PlayerId) -> GameState { + let mut s = state.clone(); + s.waiting_for = WaitingFor::RespondToPrecastCopyShortcut { + player: seat, + epoch: PRECAST_EPOCH, + breakpoint_ids: vec![PRECAST_BREAKPOINT], + remaining_players: Vec::new(), + }; + s +} + +/// The pre-cast reply the PRODUCTION candidate builder emits for this state. +/// Goes through `ai_support::candidate_actions`, i.e. the real consumer at +/// `candidates::candidate_actions_broad_with_probe`, so the +/// `ShortcutResponse` → `PrecastCopyShortcutResponse` mapping is measured too. +fn precast_candidate_response(state: &GameState) -> PrecastCopyShortcutResponse { + let replies: Vec = engine::ai_support::candidate_actions(state) + .iter() + .filter_map(|candidate| match &candidate.action { + GameAction::PrecastCopyShortcut { epoch, response } if *epoch == PRECAST_EPOCH => { + Some(response.clone()) + } + _ => None, + }) + .collect(); + assert_eq!( + replies.len(), + 1, + "reach-guard: the pre-cast responder window must offer exactly one reply candidate, \ + otherwise this row is reading something else; got {replies:?}" + ); + replies[0].clone() +} + +/// `smart_shortcut_response` is the single authority for TWO accept-or-shorten +/// windows, not one: `candidates::candidate_actions_broad_with_probe` routes +/// `WaitingFor::RespondToPrecastCopyShortcut` through it as well and maps the +/// answer onto `PrecastCopyShortcutResponse`. Stage 2 therefore changed behaviour +/// at that window too, and this row measures it instead of assuming it. +/// +/// Uniform treatment is the deliberate choice: both windows ask the identical +/// question — is a real priority window worth taking here — so a seat whose only +/// action cannot touch the loop should decline both. Arm (A) is separately +/// INAPPLICABLE here rather than merely skipped: `RespondToPrecastCopyShortcut` +/// carries no proposal summary and hence no `predicted_winner` field, so there is +/// no crown to read. Stage 1 and arm (B) both apply and both run. +/// +/// NON-VACUITY, and it is arm 2 that supplies it: `candidates.rs` maps a +/// `Shorten` with an EMPTY `breakpoint_ids` back to `Accept`, so on a +/// breakpoint-less prompt both answers would collapse to `Accept` and arm 1 +/// would pass for free. Arm 2 returns `Shorten { breakpoint_id }` off the same +/// staged breakpoint list, which proves the mapping is live and arm 1's `Accept` +/// is the efficacy verdict rather than the collapse. +/// +/// MUTANTS — both RUN, not reasoned about: +/// * `any_action_may_interfere ≡ true` ⇒ arm 1's production-path assertion fails +/// with `left: Shorten { breakpoint_id: 99 }, right: Accept`. This is also the +/// direct measurement of the non-vacuity claim above: the mapping's `Shorten` +/// branch really is reachable on this prompt. +/// * `any_action_may_interfere ≡ false` ⇒ arm 2 fails with +/// `left: Accept, right: Shorten { breakpoint_id: 99 }`. +/// +/// DROP (delete stage 2 entirely) flips arm 1 the same way and leaves arm 2 at +/// its shipped value; the first mutant covers that direction. +#[test] +fn v8_precast_window_takes_the_same_efficacy_answer() { + // Arm 1 — the confined fetchland seat. + let (mut runner, kickoff) = optional_cascade(|s| { + s.add_land_from_oracle(P2, "Terramorphic Expanse", TERRAMORPHIC); + }); + respond_window_at(&mut runner, kickoff, P2); + let fetch_precast = as_precast_window(runner.state(), P2); + + assert!( + stage_one_meaningful(&fetch_precast, P2), + "REACH-GUARD: stage 1 must still say `meaningful` at the PRE-CAST window, or this arm \ + measures the stage-1 path and says nothing about stage 2; got {:?}", + non_pass_actions(&fetch_precast, P2) + ); + // The PRODUCTION-PATH assertion comes first deliberately: it is the one that + // has to discriminate, and an authority-level assertion ahead of it would + // absorb every mutant before the candidate builder was ever exercised. + assert_eq!( + precast_candidate_response(&fetch_precast), + PrecastCopyShortcutResponse::Accept, + "the pre-cast candidate builder must carry stage 2's answer through: a window whose only \ + content is cracking one's own fetchland is worth no more on the pre-cast route than on \ + the generic one" + ); + assert_eq!( + engine::ai_support::smart_shortcut_response(&fetch_precast, P2), + ShortcutResponse::Accept, + "and the authority itself answers identically at both windows" + ); + + // Arm 2 — the same board plus real interaction. The window is still granted. + let (mut armed, armed_kickoff) = optional_cascade(|s| { + s.add_land_from_oracle(P2, "Terramorphic Expanse", TERRAMORPHIC); + s.add_basic_land(P2, ManaColor::Red); + s.add_bolt_to_hand(P2); + }); + respond_window_at(&mut armed, armed_kickoff, P2); + let armed_precast = as_precast_window(armed.state(), P2); + + assert!( + probe_actions(&armed_precast, P2) + .iter() + .any(|a| matches!(a, GameAction::CastSpell { .. })), + "reach-guard: the Bolt must be castable at the pre-cast window too, or this arm repeats \ + arm 1; got {:?}", + non_pass_actions(&armed_precast, P2) + ); + assert_eq!( + precast_candidate_response(&armed_precast), + PrecastCopyShortcutResponse::Shorten { + breakpoint_id: PRECAST_BREAKPOINT + }, + "ACCEPTANCE (b) on the pre-cast route: a seat holding real interaction keeps its window, \ + named at the breakpoint the engine issued to it. This is also arm 1's non-vacuity proof \ + — the Shorten branch of the mapping is reachable on this exact prompt" + ); +} + +// =========================================================================== +// V9 — COVERAGE INVARIANT: stage 2 classifies everything stage 1 counted. +// =========================================================================== + +/// Krark-Clan Ironworks, verbatim, verified byte-for-byte against MTGJSON +/// `AtomicCards.json` (`.data["Krark-Clan Ironworks"][0].text`; `.types` is +/// `["Artifact"]`). CARD PROVENANCE, in the sense the header block above defines +/// — it is the shape under test, so a paraphrase could take a different parser +/// branch and go green while the real card stayed broken. +/// +/// Why THIS card: its activation is the issue #544 shape — a sacrifice-for-mana +/// ability that `legal_actions` structurally omits while +/// `has_meaningful_priority_action`'s second rung still counts it off `state`. +/// That gap between the two stages' inputs is the whole subject of this section. +const IRONWORKS: &str = "Sacrifice an artifact: Add {C}{C}."; + +/// Stage the Ironworks on `player`'s battlefield, ability taken from the REAL +/// parser (see `give_parsed_card`, which this is now one call into). +/// +/// It was an inlined copy of that helper until the two were diffed field by +/// field and found byte-equivalent — same parse call, same assertion text once +/// `name` is substituted, same `create_object` arguments, same core-type push, +/// same `base_card_types`/`abilities`/`base_abilities` assignment, same +/// `layers_dirty`. Delegating is behaviour-identical BY CONSTRUCTION, and the +/// divergence it prevents already fired once inside this same change: +/// `base_mana_cost` reached one construction path and not the other. +fn give_ironworks(state: &mut GameState, player: PlayerId) -> ObjectId { + give_parsed_card( + state, + player, + "Krark-Clan Ironworks", + IRONWORKS, + CoreType::Artifact, + Zone::Battlefield, + ) +} + +/// A vanilla artifact for the Ironworks to eat. No abilities and no card claim: +/// it exists so the sacrifice cost is payable, and it must contribute no action +/// of its own or it would give the fold a second way to reach `Shorten`. +fn give_artifact_fodder(state: &mut GameState, player: PlayerId) -> ObjectId { + let id = engine::game::zones::create_object( + state, + CardId(state.next_object_id), + player, + "Test Artifact Fodder".to_string(), + Zone::Battlefield, + ); + let obj = state.objects.get_mut(&id).expect("just created"); + obj.card_types + .core_types + .push(engine::types::card_type::CoreType::Artifact); + obj.base_card_types = obj.card_types.clone(); + obj.summoning_sick = false; + state.layers_dirty = LayersDirty::full(); + id +} + +/// The optional-cascade board with an Ironworks + one artifact staged on P2 +/// AFTER the response window opens, so the addition cannot perturb the drive to +/// the offer. +fn ironworks_seat_polled() -> (GameRunner, ObjectId) { + let (mut runner, kickoff) = optional_cascade(|_| {}); + respond_window_at(&mut runner, kickoff, P2); + let ironworks = give_ironworks(runner.state_mut(), P2); + let _fodder = give_artifact_fodder(runner.state_mut(), P2); + (runner, ironworks) +} + +/// The invariant, asserted directly on the set rather than inferred from a +/// verdict: **stage 2 folds over every action stage 1 counted as meaningful.** +/// +/// This is the row that survives a future reclassification. `v9b` below reads +/// the Ironworks' *verdict*, which a later, more precise `filter_is_actor_owned` +/// could legitimately flip (CR 701.21a: "A player can't sacrifice something that +/// isn't a permanent, or something that's a permanent they don't control" — so +/// "Sacrifice an artifact" IS actor-owned in fact, merely not PROVEN so by this +/// AST). This row does not depend on the verdict at all — it +/// pins that the action is *handed to the classifier*, which is what keeps a +/// newly added stage-1 rung from silently reintroducing Accept-by-omission. +/// +/// The three assertions are the defect's three premises, in order: +/// 1. the activation is ABSENT from the flat list (issue #544 grouping), so +/// 2. stage 1 nonetheless counts it — via the `state`-reading second rung — and +/// 3. `stage_two_action_set` therefore has to put it back, or the two stages +/// read different inputs. +/// +/// Revert-probe (EXECUTED, see the report): defining `stage_two_action_set` as +/// `flat_actions.to_vec()` fails assertion 3. +#[test] +fn v9a_stage_two_folds_over_every_action_stage_one_counted() { + let (runner, ironworks) = ironworks_seat_polled(); + let activation = GameAction::ActivateAbility { + source_id: ironworks, + ability_index: 0, + }; + + let (probe, flat) = engine::ai_support::shortcut_probe(runner.state(), P2); + assert!( + !flat.contains(&activation), + "PREMISE 1: sacrifice-for-mana stays out of the flat priority list (issue #544) — if it \ + were present, the two stages would already agree and this row would be vacuous; got \ + {flat:?}" + ); + assert!( + stage_one_meaningful(runner.state(), P2), + "PREMISE 2: stage 1 counts it anyway, off `state` rather than off that list — this is the \ + asymmetry the invariant exists to close; got {flat:?}" + ); + + let stage_two = engine::ai_support::stage_two_action_set(probe.state(), &flat); + assert!( + stage_two.contains(&activation), + "THE INVARIANT: every action stage 1 counted as meaningful must be handed to stage 2. An \ + action the classifier never sees reaches no arm, so the fail-closed default cannot save \ + it and the seat Accepts BY OMISSION; got {stage_two:?}" + ); +} + +/// The response-level discriminator: with the invariant restored, this seat +/// Shortens; without it, it Accepts. +/// +/// It discriminates because the Ironworks activation classifies `MayInterfere`, +/// and since V10 it does so through TWO independent legs. Its `Sacrifice` cost +/// filter (`Typed{Artifact}`) names no controller, so `filter_is_actor_owned` +/// cannot PROVE actor ownership and `cost_window_reach` takes the fail-closed +/// direction; and its `Effect::Mana` head is no longer allowlisted either, so +/// the head alone would carry the verdict. +/// +/// The counterfactual this doc used to carry — "a sacrifice ability whose filter +/// *were* proven actor-owned would classify `OwnResourcesOnly` … i.e. would not +/// discriminate" — is FALSE since `Effect::Mana` left the allowlist, and +/// `v10b` is the row that refutes it on exactly that shape (Lotus Petal's +/// `SelfRef` sacrifice IS proven actor-owned, and the seat still Shortens). What +/// survives is the sentence's purpose: the unproven filter is still what makes +/// THIS row's own MUTANT discriminate, because that mutant deletes the widening +/// rather than touching the classifier. +/// +/// REACH-GUARD: the flat list is asserted to be EXACTLY `[PassPriority]`. That +/// is what makes the verdict attributable: `PassPriority` is the classifier's +/// one `false` arm, so the flat half cannot reach `Shorten` on its own and the +/// only action that can is the one the widening added. +/// +/// MUTANT (EXECUTED, see the report): `stage_two_action_set ≡ flat_actions +/// .to_vec()` — i.e. delete the widening — flips this row to `Accept`. +/// +/// This row's verdict is now OVER-DETERMINED, which changes what a future +/// refinement does to it. The doc used to predict that a `filter_is_actor_owned` +/// which learns to prove "Sacrifice an artifact" actor-owned (CR 701.21a bounds +/// the actor to permanents they CONTROL, which is the fact such a refinement +/// would be reading) would red this row; it will not, because the unallowlisted +/// `Effect::Mana` head carries `MayInterfere` unconditionally. The guidance the +/// prediction carried still stands and is the part to keep: if this row ever +/// does red, re-derive the fixture on an ability whose reach is genuinely +/// unproven — NOT delete the row, and NOT weaken the classifier. Its +/// discriminating power comes from its MUTANT rather than from the cost filter's +/// imprecision, and `v9a` above holds the invariant meanwhile. +#[test] +fn v9b_a_sacrifice_for_mana_seat_still_gets_its_window() { + let (runner, ironworks) = ironworks_seat_polled(); + let activation = GameAction::ActivateAbility { + source_id: ironworks, + ability_index: 0, + }; + + let flat = probe_actions(runner.state(), P2); + assert_eq!( + flat, + vec![GameAction::PassPriority], + "REACH-GUARD: the flat half must be exactly the one action the classifier answers `false` \ + on, or a `Shorten` here is not attributable to the widening; got {flat:?}" + ); + assert!( + stage_one_meaningful(runner.state(), P2), + "reach-guard: stage 1 must return true, or the seat resolves at stage 1 and never reaches \ + the fold under test" + ); + + assert_eq!( + engine::ai_support::smart_shortcut_response(runner.state(), P2), + ShortcutResponse::Shorten { at_iteration: 0 }, + "a seat whose only meaningful action is {activation:?} must keep its window: the AST does \ + not prove the sacrificed artifact is this seat's own, and stage 2 must not Accept a \ + reach it cannot rule out" + ); +} + +// =========================================================================== +// V10 — MANA IS FUNGIBLE REACH, not a confined own resource. +// +// CR 106.4's first sentence ("that mana goes into a player's mana pool") is the +// half an earlier `Effect::Mana` arm quoted; the rest of the same rule says the +// mana "can be used to pay costs immediately", CR 106.1 says paying costs is +// mana's whole function, and CR 601.2g runs mana abilities during the very cast +// they fund. So producing mana widens what the polled seat can do inside the +// window `game::engine`'s `RespondToShortcut(Shorten)` arm hands back, and the +// classifier — which reads ONE ability's AST and no other object — cannot prove +// otherwise. Both rows below ride the REAL 4p board through the same production +// chokepoint the flagship uses. +// =========================================================================== + +/// Dark Ritual, verbatim. CARD PROVENANCE in the sense the header block defines. +/// The `Effect::Mana` head with `cost: None` is the point — nothing but the head +/// can carry this object's verdict. +const DARK_RITUAL: &str = "Add {B}{B}{B}."; + +/// Lotus Petal, verbatim. CARD PROVENANCE. This is the maintainer's named +/// "actor-owned sacrifice-for-mana" class: the parser emits "Sacrifice this +/// artifact" as a `TargetFilter::SelfRef`, which `filter_is_actor_owned` +/// returns true for, so the cost leg reads confined and the mana head carries +/// the verdict ALONE. CR 701.21a bounds the actor to permanents they CONTROL, +/// which is the most that predicate can be grounded in — its first sentence +/// sends the sacrificed card to its OWNER's graveyard, so control is not +/// ownership and "confined" is narrower than the predicate's name suggests. +/// `shortcut_efficacy`'s `mana_production_is_reach_not_a_confined_own_resource` +/// quotes the rule in full and names the limit; nothing here rests on it, +/// because the mana head decides this verdict either way. That is also what +/// makes this row not a second `v9b`, +/// whose Ironworks reaches the same verdict through an UNPROVEN cost filter. +const LOTUS_PETAL: &str = "{T}, Sacrifice this artifact: Add one mana of any color."; + +/// Sol Ring, verbatim. CARD PROVENANCE. The ORDINARY mana source: no sacrifice +/// leg, so `mana_ability_penalty` is `None` rather than `Sacrifices` and the +/// stage-2 widening must not re-admit it. +const SOL_RING: &str = "{T}: Add {C}{C}."; + +/// Crop Rotation, verbatim, BOTH lines. CARD PROVENANCE (MTGJSON +/// `.data["Crop Rotation"][0].text`). The UNTAPPED fetch — the residual the +/// `enter_tapped` gate closes, and the class `Terramorphic Expanse` is NOT. +/// +/// MEASURED on the live parser: this text yields exactly ONE ability with +/// `cost: None` — the additional-cost line is not carried — heading +/// `SearchLibrary { filter: Typed[Land], target_player: None }` over a +/// `ChangeZone { Library -> Battlefield, target: Any, enter_tapped: Unspecified }` +/// over `Shuffle { Controller }`. So every leg but the tap state reads confined, +/// which is precisely why this card was allowlisted before the gate. Its search +/// filter is ANY land card, which is what lets the funding lemma below find a +/// real basic Swamp in P2's own recorded library instead of staging one. +const CROP_ROTATION: &str = "As an additional cost to cast this spell, sacrifice a land.\n\ + Search your library for a land card, put that card onto the \ + battlefield, then shuffle."; + +/// Rampant Growth, verbatim. CARD PROVENANCE. The TAPPED sibling and the whole +/// point of the matched pair: one printed word apart from Crop Rotation on every +/// axis this classifier reads, and the fetched land arrives unable to pay for +/// anything. +const RAMPANT_GROWTH: &str = "Search your library for a basic land card, put that card onto \ + the battlefield tapped, then shuffle."; + +/// Reshape the Earth, verbatim. CARD PROVENANCE — Oracle text verified on +/// Scryfall (`/cards/named?exact=Reshape+the+Earth`), a Sorcery whose whole +/// printed text is this one sentence. +/// +/// Rampant Growth's UNRESTRICTED sibling, and the second one-word-apart pair in +/// this file: identical AST on every axis this classifier reads — no cost, one +/// ability, `SearchLibrary` over `ChangeZone { Library -> Battlefield, target: +/// Any, enter_tapped: Tapped }` over `Shuffle { Controller }`, no triggers, no +/// statics, no replacements, no keywords — and the search filter is +/// `Typed[Land]` where Rampant Growth's is `Typed[Land] + HasSupertype(Basic)`. +/// So the only thing that can separate their verdicts is WHICH CARDS THE ACTOR +/// COULD SELECT. +/// +/// Elvish Reclaimer is the card the review named for this row and it CANNOT +/// serve, which is worth stating rather than leaving as an unexplained +/// substitution. Its ability costs "Sacrifice a land" — parsed +/// `AbilityCost::Sacrifice(Typed[Land])` with `controller: null` — and +/// `filter_is_actor_owned` proves nothing about an unqualified `Typed`, so +/// `cost_window_reach` already answers `MayInterfere` for it. It reads +/// `Shorten` with the selection gate and `Shorten` without it: an +/// over-determined row that would go green while measuring nothing. +const RESHAPE_THE_EARTH: &str = "Search your library for up to ten land cards, put them onto \ + the battlefield tapped, then shuffle."; + +/// Stage a real card with its abilities taken from the REAL parser, not +/// hand-built: every verdict below is a function of the AST, so a hand-written +/// `AbilityDefinition` would let this section pass against a shape the pipeline +/// never produces. Parameterized over the three axes the V10 rows vary, and the +/// SINGLE staging path for parsed cards in this file — `give_ironworks` above +/// delegates here rather than keeping the byte-equivalent copy it used to be. +fn give_parsed_card( + state: &mut GameState, + player: PlayerId, + name: &str, + oracle: &str, + core_type: CoreType, + zone: Zone, +) -> ObjectId { + let parsed = engine::parser::oracle::parse_oracle_text(oracle, name, &[], &[], &[]); + assert_eq!( + parsed.abilities.len(), + 1, + "PREMISE: {name} parses to exactly one ability; got {:?}", + parsed.abilities + ); + let id = engine::game::zones::create_object( + state, + CardId(state.next_object_id), + player, + name.to_string(), + zone, + ); + let obj = state.objects.get_mut(&id).expect("just created"); + obj.card_types.core_types.push(core_type); + obj.base_card_types = obj.card_types.clone(); + // No `summoning_sick = false` here: it would be a no-op that reads as + // load-bearing. `zones::create_object` documents that it deliberately does + // NOT set the flag (only the real ETB pipeline's + // `reset_for_battlefield_entry` does), `add_to_zone` never touches it, and + // `GameObject::new` already defaults it to `false`. + obj.abilities = std::sync::Arc::new(parsed.abilities.clone()); + obj.base_abilities = std::sync::Arc::new(parsed.abilities); + state.layers_dirty = LayersDirty::full(); + id +} + +/// The Ritual is staged as an INSTANT and with no printed mana cost, and both +/// are deliberate. Without a core type the sorcery-timing gate refuses the cast +/// at this window and the funder never enters the action set — the row would go +/// vacuous silently. And Dark Ritual's real printed cost is `{B}` while P2 +/// controls no mana source, so a printed-cost Ritual would itself be uncastable +/// and the row would measure an empty board twice. What the row measures is the +/// CLASSIFIER, which reads the AST and never the mana cost. +fn give_dark_ritual(state: &mut GameState, player: PlayerId) -> ObjectId { + give_parsed_card( + state, + player, + "Dark Ritual", + DARK_RITUAL, + CoreType::Instant, + Zone::Hand, + ) +} + +/// THE SIZING SITE. This source contributes **exactly 1** to +/// `game::mana_sources`' `feasible_mana_capacity`: its `AnyOneColor` production +/// carries `count: Fixed { value: 1 }`, and that arm of +/// `game::effects::mana`'s `resolve_mana_types_for_ability` returns +/// `vec![mana_type; amount]` — length `amount`, NOT `color_options.len()`. +/// +/// MEASURED on this fixture, the only mana-gated action P2 owns at this window +/// is Angel of the Ruins' hand-zone plainscycling (object 210), whose cost is +/// `Composite[Mana{generic 2}, Discard{self_ref}]` — `{2}` generic. **The margin +/// is exactly 1 mana.** ANY staged P2 source contributing 2 or more unlocks that +/// cycling, puts an `ActivateAbility` in P2's flat list, and destroys `v10b`'s +/// attribution — its `non_pass` assertion is what fails, loudly, if that +/// happens. A future edit that raises this source's capacity silently destroys +/// the discrimination, so do NOT "strengthen" it. +fn give_lotus_petal(state: &mut GameState, player: PlayerId) -> ObjectId { + give_parsed_card( + state, + player, + "Lotus Petal", + LOTUS_PETAL, + CoreType::Artifact, + Zone::Battlefield, + ) +} + +/// Both fetches are staged as INSTANTS with no printed mana cost, for +/// `give_dark_ritual`'s reasons: without a core type the sorcery-timing gate +/// refuses the cast at this window and the funder never enters the action set, +/// and P2 controls no mana source so a printed cost would make the funder itself +/// uncastable and the row would measure an empty board twice. +/// +/// The type is card-faithful for Crop Rotation (a real instant) and is NOT for +/// Rampant Growth (a real sorcery). That deviation is deliberate and stated: the +/// control's subject is the TAP STATE of the fetched land, and putting the two +/// fetches on different timing rails would confound exactly that axis. +fn give_crop_rotation(state: &mut GameState, player: PlayerId) -> ObjectId { + give_parsed_card( + state, + player, + "Crop Rotation", + CROP_ROTATION, + CoreType::Instant, + Zone::Hand, + ) +} + +/// The tapped half of `v10c`'s pair. See `give_crop_rotation` for the staging. +fn give_rampant_growth(state: &mut GameState, player: PlayerId) -> ObjectId { + give_parsed_card( + state, + player, + "Rampant Growth", + RAMPANT_GROWTH, + CoreType::Instant, + Zone::Hand, + ) +} + +/// Cast `fetch` at P2's OWN probe priority and drive real `apply()` beats until +/// it resolves, selecting a basic **Swamp** at the search prompt. Returns the +/// resulting state and the fetched land. +/// +/// The Swamp is chosen by NAME rather than taken as the prompt's first offer: +/// several of the lands in P2's recorded library carry their own "enters tapped" +/// clause (Path of Ancestry, Goldmire Bridge, Temple of Silence, ...), and one of +/// those would make the funding lemma measure the FETCHED CARD's printed text +/// instead of the fetch effect's `enter_tapped` rider — the very axis under test. +fn resolve_fetch_choosing_a_swamp(arm: &GameState, fetch: ObjectId) -> (GameState, ObjectId) { + let (probe, list) = engine::ai_support::shortcut_probe(arm, P2); + let cast = list + .iter() + .find(|a| matches!(a, GameAction::CastSpell { object_id, .. } if *object_id == fetch)) + .cloned() + .expect("reach-guard: the fetch must be castable at P2's own probe priority"); + let mut state = probe.state().clone(); + // This drive is the first thing in this file to reach a library SHUFFLE, and + // a shuffle is the one operation that reads the live ChaCha20 stream. + // `restore_dump` above decodes through `into_game_state()`, which does NOT + // reseed the `#[serde(skip)]` `rng` — production's restore does that in a + // SECOND step (`engine-wasm`'s `restore_game_state` calls + // `GameState::rehydrate_rng` right after decoding, issue #5466). Without it + // the live stream sits at offset 0 while this dump's `rng_word_pos` is 313, + // and `game::library`'s `capture_rng_word_pos` fails the entropy-high-water + // invariant. Doing it here rather than in `restore_dump` keeps every other + // row in this file byte-identical: the reseed lands only on this quarantined + // funding clone, which no verdict is ever taken on. + state.rehydrate_rng(); + apply(&mut state, P2, cast).expect("the fetch casts at P2's own probe priority"); + + let mut chosen: Option = None; + for _ in 0..60 { + let prompt = match &state.waiting_for { + WaitingFor::SearchChoice { player, cards, .. } => Some((*player, cards.clone())), + _ => None, + }; + if let Some((player, cards)) = prompt { + let swamp = cards + .iter() + .copied() + .find(|id| state.objects.get(id).is_some_and(|o| o.name == "Swamp")) + .expect("P2's recorded library must offer a basic Swamp to this search"); + apply( + &mut state, + player, + GameAction::SelectCards { cards: vec![swamp] }, + ) + .expect("the searcher selects the Swamp"); + chosen = Some(swamp); + continue; + } + if chosen.is_some() && !state.stack.iter().any(|entry| entry.id == fetch) { + break; + } + dump_drive_one_beat(&mut state).expect("passing priority resolves the top of the stack"); + } + ( + state, + chosen.expect("reach-guard: the fetch must have PROMPTED a library search"), + ) +} + +/// Capacity **2** (`Colorless { count: Fixed 2 }`), which is why the control it +/// serves asserts re-admission ONLY and never a verdict — see `v10b`. +fn give_sol_ring(state: &mut GameState, player: PlayerId) -> ObjectId { + give_parsed_card( + state, + player, + "Sol Ring", + SOL_RING, + CoreType::Artifact, + Zone::Battlefield, + ) +} + +/// V10a — a CAST mana spell funds an otherwise-unaffordable answer, so the seat +/// must keep its window. +/// +/// The pair varies exactly one object: a Dark Ritual in P2's hand. Both arms +/// hold the same `{B}{B}{B}` Bolt, and assertion 1 is the operational definition +/// of "otherwise-unaffordable" — `feasible_mana_capacity` is battlefield-scoped, +/// so a Ritual sitting in HAND contributes 0 and the castability gate +/// structurally cannot see "cast a ritual first, then the Bolt". That two-step +/// is exactly what the priority window buys, and CR 601.2g / CR 117.1d are the +/// rules that make the mana available to pay a cost the moment it is produced. +/// +/// BOTH halves of "otherwise-unaffordable" are measured, as in `v10b`. The +/// negative half is assertion 1 above, in both arms. The positive half is the +/// QUARANTINED funding lemma at the end of the row: the Ritual is driven through +/// the stack on the `apply()` boundary and the SAME Bolt is re-probed, so the +/// arithmetic (`{B}{B}{B}` added against `{B}{B}{B}` printed) is read off the +/// engine's castability gate rather than off the verbatim texts. +/// +/// MUTANT: restoring `Effect::Mana {..} => WindowReach::OwnResourcesOnly` as +/// `effect_window_reach`'s first arm flips the SHORTEN arm to `Accept`. Under it +/// the Ritual's single ability folds `OwnResourcesOnly` (head `Effect::Mana`, +/// `cost: None`), and P2's remaining actions are `PassPriority` — the +/// classifier's one `false` arm — and the fetchland, whose verdict rides +/// untouched arms. The ACCEPT arm is unaffected by the mutation by construction: +/// its action set carries no `Effect::Mana` node at all. +/// +/// Every `GameAction::CastSpell` matcher below binds `{ object_id, .. }` and +/// must NOT name `payment_mode`: a Petal- or Ritual-funded cast can be offered +/// as `CastPaymentMode::AutoExceptSacrificialMana`, and a mode-specific matcher +/// would fail with a message claiming the funding does not work. +#[test] +fn v10a_a_cast_mana_spell_that_funds_an_unaffordable_answer_keeps_its_window() { + let mut board = live_path_board(); + drive_to_offer(&mut board, 400).expect("CR 732.2a: the offer must fire on this real 4p drain"); + // ONE drive, ONE poll, shared by both arms — so staging cannot perturb the + // drive, the offer schema, or the APNAP walk. + let polled = declare_and_poll(&board, P2); + + let mut base = polled.clone(); + let bolt = give_bolt_with_cost( + &mut base, + P2, + // `{B}{B}{B}` — sized to exactly what one Dark Ritual adds. + ManaCost::Cost { + shards: vec![ManaCostShard::Black; 3], + generic: 0, + }, + ); + + // ── arm ACCEPT: the interaction alone ── + let accept_arm = base.clone(); + // ── arm SHORTEN: same board, same Bolt, PLUS the funding piece ── + let mut shorten_arm = base.clone(); + let ritual = give_dark_ritual(&mut shorten_arm, P2); + + for (label, arm) in [("ACCEPT", &accept_arm), ("SHORTEN", &shorten_arm)] { + assert!( + !probe_actions(arm, P2).iter().any( + |a| matches!(a, GameAction::CastSpell { object_id, .. } if *object_id == bolt) + ), + "PREMISE ({label} arm): the Bolt must be OTHERWISE-UNAFFORDABLE at poll time — a \ + hand-zone Ritual is invisible to the battlefield-scoped capacity scan, so the \ + castability gate cannot see the two-step the window buys; got {:?}", + non_pass_actions(arm, P2) + ); + assert!( + stage_one_meaningful(arm, P2), + "reach-guard ({label} arm): stage 1 must return true, or the seat answers at stage 1 \ + and the fold under test never runs" + ); + } + + assert!( + probe_actions(&shorten_arm, P2) + .iter() + .any(|a| matches!(a, GameAction::CastSpell { object_id, .. } if *object_id == ritual)), + "reach-guard: the FUNDER must really be castable, or the SHORTEN arm is the ACCEPT arm \ + with extra steps; got {:?}", + non_pass_actions(&shorten_arm, P2) + ); + + let shorten_non_pass = non_pass_actions(&shorten_arm, P2); + assert_eq!( + shorten_non_pass.len(), + 2, + "ATTRIBUTION + THRESHOLD SENTINEL, mirroring `v10b`'s: the SHORTEN arm must be the \ + ACCEPT arm's single fetchland PLUS the Ritual cast, and nothing else. The Ritual sits \ + in `Zone::Hand`, which the battlefield-scoped `feasible_mana_capacity` cannot see, so \ + it unlocks no third action — but that is DERIVED, and a fixture or capacity change \ + that added one `MayInterfere` action here would over-determine this row silently \ + instead of reddening. MEASURED at 2 on the committed fixture; got {shorten_non_pass:?}" + ); + + let accept_non_pass = non_pass_actions(&accept_arm, P2); + assert_eq!( + accept_non_pass.len(), + 1, + "ATTRIBUTION: the ACCEPT arm's action set must be the flagship's exactly, so its Accept \ + is the already-shipped verdict and the pair's ONLY variable is the Ritual; got \ + {accept_non_pass:?}" + ); + assert!( + accept_non_pass[0].contains("Terramorphic Expanse") + && accept_non_pass[0].contains("zone=Some(Battlefield)") + && accept_non_pass[0].contains("controller=Some(PlayerId(2))"), + "ATTRIBUTION: that one action must be P2's OWN battlefield fetchland; got \ + {accept_non_pass:?}" + ); + + // IDENTITY, not just cardinality. The sentinel above bounds the SHORTEN + // arm's COUNT; this bounds its MEMBERSHIP, which is what that sentinel's + // prose actually claims ("the ACCEPT arm's single fetchland PLUS the Ritual + // cast, and nothing else"). Without this pair, a fixture or capacity change + // that DROPPED the fetchland and added some unrelated `MayInterfere` action + // still satisfies `len() == 2` AND the ritual reach-guard, and the row + // silently measures the wrong pair. + // + // The fetchland is the same fixture object in both arms — both are clones + // of one `base`, and only the Ritual is staged on top — so + // `accept_non_pass[0]` is reusable verbatim instead of re-typing the three + // `contains` substrings. MEASURED: the two formatted strings are byte-equal + // (`ObjectId(203)` in both arms). + // + // The Ritual leg is matched on `object_id` ONLY. It must NOT name + // `payment_mode`, for the reason this test's doc comment gives. + let (_ritual_leg, other_legs): (Vec<&String>, Vec<&String>) = shorten_non_pass + .iter() + .partition(|a| a.starts_with(&format!("CastSpell {{ object_id: {ritual:?},"))); + assert_eq!( + other_legs, + vec![&accept_non_pass[0]], + "ATTRIBUTION: the SHORTEN arm's set MINUS the Ritual must be the ACCEPT arm's set \ + EXACTLY — same fetchland object, same zone, same controller. Anything else means the \ + pair varies more than the single object it claims to vary, and the Shorten verdict below \ + is no longer attributable to the Ritual. This one equality also pins the Ritual leg: if \ + the partition matched nothing, `other_legs` carries both members and reddens; if it \ + matched both, `other_legs` is empty and reddens; got {shorten_non_pass:?}" + ); + + assert_eq!( + engine::ai_support::smart_shortcut_response(&accept_arm, P2), + ShortcutResponse::Accept, + "the pair's negative arm: an unaffordable answer and a confined fetchland buy nothing" + ); + assert_eq!( + engine::ai_support::smart_shortcut_response(&shorten_arm, P2), + ShortcutResponse::Shorten { at_iteration: 0 }, + "the pair's positive arm: producing mana is REACH (CR 106.1 / CR 106.4 / CR 601.2g). \ + The ritual is the one object that differs, and accepting here surrenders a live out" + ); + + // ── FUNDING LEMMA (CR 117.1d + CR 601.2g), deliberately QUARANTINED ── + // + // The negative half is already asserted above: the `{B}{B}{B}` Bolt is NOT + // castable in EITHER arm at poll time. This is the positive half — the half + // this row's title claims ("funds an unaffordable answer") — measured on the + // production instrument rather than left to the verbatim texts' arithmetic. + // + // QUARANTINE, mirroring `v10b`'s lemma: this clone NEVER reaches + // `smart_shortcut_response`. Both verdicts above are already taken; resolving + // the Ritual inside an arm would change the very action set they were taken + // on, and the funded board additionally unlocks the Angel's `{2}` cycling + // (`give_lotus_petal`'s capacity note), which carries `MayInterfere` on a + // route this pair does not model. + let (probe, probe_list) = engine::ai_support::shortcut_probe(&shorten_arm, P2); + let cast_ritual = probe_list + .iter() + .find(|a| matches!(a, GameAction::CastSpell { object_id, .. } if *object_id == ritual)) + .cloned() + .expect("the funder is castable — the reach-guard above asserts exactly this"); + let mut funded = probe.state().clone(); + apply(&mut funded, P2, cast_ritual).expect("the funder casts at P2's own probe priority"); + // Pass beats until the Ritual leaves the stack: it is on top, so the first + // full pass round resolves it (MEASURED: 4 beats on this 4p board). + for _ in 0..40 { + if !funded.stack.iter().any(|entry| entry.id == ritual) { + break; + } + dump_drive_one_beat(&mut funded).expect("passing priority resolves the top of the stack"); + } + assert_eq!( + funded.objects.get(&ritual).map(|o| o.zone), + Some(Zone::Graveyard), + "reach-guard: the Ritual must have RESOLVED, not merely been cast — CR 608.2n puts a \ + resolved instant into its owner's graveyard, so this is the observable that separates \ + 'it resolved' from 'the drive capped out' and would otherwise red the funding \ + assertion below for the wrong reason" + ); + assert!( + probe_actions(&funded, P2) + .iter() + .any(|a| matches!(a, GameAction::CastSpell { object_id, .. } if *object_id == bolt)), + "FUNDING: with the Ritual resolved, the engine's own castability gate says the SAME \ + `{{B}}{{B}}{{B}}` Bolt that is unaffordable in BOTH arms above IS payable. That is the \ + two-step the priority window buys (CR 117.1d / CR 601.2g), measured rather than \ + inferred from the printed texts; got {:?}", + non_pass_actions(&funded, P2) + ); +} + +/// V10b — an ACTOR-OWNED sacrifice-for-mana seat keeps its window. This is the +/// maintainer's named class, and it is the row `v9b` structurally cannot be. +/// +/// The pair varies exactly ONE object: a Lotus Petal on P2's battlefield. No +/// Bolt, no Swamp, no second permanent. +/// +/// **CAPACITY THRESHOLD — the sizing this row lives or dies on.** The staged +/// source contributes exactly **1** to `feasible_mana_capacity` (see +/// `give_lotus_petal`), and the only mana-gated action P2 owns at this window is +/// Angel of the Ruins' hand-zone plainscycling at `{2}` generic. **The margin is +/// exactly 1 mana.** ANY staged P2 source contributing 2 or more unlocks that +/// cycling, puts an `ActivateAbility` in P2's flat list, and destroys this row's +/// attribution — the `non_pass` assertion below is what fails, loudly, if that +/// happens. A row that reds that way is a fixture-threshold artifact, not a +/// defect in the classifier: check whether `non_pass_actions` names object 210 +/// first, shrink the staged source, and do NOT respond by relaxing an +/// `Effect::Mana` classification. +/// +/// **P2's full reachable surface at this window, enumerated so the threshold is +/// not a claim about the hand alone.** MEASURED from the committed fixture: +/// battlefield 1 (Terramorphic Expanse, capacity 0); hand 7, of which six are +/// sorcery-speed (`Victimize`, `Plains`, `Arcane Signet`, `Commander's Sphere`, +/// `Compleated Huntmaster`, `Night's Whisper`) and only the Angel offers a +/// mana-gated instant-speed action; command zone 1 — `Brimaz, Blight of +/// Oreskos`, `{2}{W}{B}`, a CREATURE, and `format_config.command_zone` is true, +/// so `casting::spell_objects_available_to_cast`'s `Zone::Command` clause does +/// put it inside the candidate loop. The fixture is `active_player` 0 in +/// `CombatDamage` with priority at P2, so it is neither P2's turn nor a main +/// phase and sorcery-speed timing blocks Brimaz — and no amount of mana lifts a +/// timing gate. Library-zone activations are gated `is_active && stack_empty` +/// and P2 is not active. So `{2}` really is the whole threshold, over the whole +/// surface rather than over the hand. +/// +/// MUTANT: restoring `Effect::Mana {..} => WindowReach::OwnResourcesOnly` flips +/// the SHORTEN arm to `Accept`. The Petal's cost leg is ALREADY +/// `OwnResourcesOnly` — `Composite[Tap, Sacrifice{SelfRef}]`, and +/// `filter_is_actor_owned` proves `SelfRef` at its first match arm — so with the +/// mana arm restored the whole ability folds `OwnResourcesOnly`. That is exactly +/// why this row is not a second `v9b`, whose verdict rides its UNPROVEN cost +/// filter and is untouched by the mutation. +#[test] +fn v10b_an_actor_owned_sacrifice_for_mana_seat_keeps_its_window() { + let mut board = live_path_board(); + drive_to_offer(&mut board, 400).expect("CR 732.2a: the offer must fire on this real 4p drain"); + let polled = declare_and_poll(&board, P2); + + // ── arm ACCEPT: the polled board, untouched ── + let accept_arm = polled.clone(); + // ── arm SHORTEN: the polled board plus ONE object ── + let mut shorten_arm = polled.clone(); + let petal = give_lotus_petal(&mut shorten_arm, P2); + let activation = GameAction::ActivateAbility { + source_id: petal, + ability_index: 0, + }; + + assert!( + !probe_actions(&shorten_arm, P2).contains(&activation), + "PREMISE: the Petal's ability IS a mana ability (CR 605.1a), so candidate generation \ + excludes it from the flat list outright. This is the issue-#544 asymmetry the whole \ + V9/V10 section exists for — if it were present, stage 2 would already see it and the \ + widening below would be vacuous" + ); + + let non_pass = non_pass_actions(&shorten_arm, P2); + assert_eq!( + non_pass.len(), + 1, + "ATTRIBUTION + THRESHOLD SENTINEL: if the Petal's mana made ANY P2 action affordable — a \ + hand card, or the Angel's {{2}} cycling — it would appear here and could carry \ + MayInterfere independently of the arm under test. See this row's capacity-threshold \ + note before touching the staged source; got {non_pass:?}" + ); + assert!( + non_pass[0].contains("Terramorphic Expanse") + && non_pass[0].contains("zone=Some(Battlefield)") + && non_pass[0].contains("controller=Some(PlayerId(2))"), + "ATTRIBUTION: that one action must still be P2's OWN battlefield fetchland; got \ + {non_pass:?}" + ); + + let (probe, flat) = engine::ai_support::shortcut_probe(&shorten_arm, P2); + let mut expected = flat.clone(); + expected.push(activation); + assert_eq!( + engine::ai_support::stage_two_action_set(probe.state(), &flat), + expected, + "the widening added EXACTLY the Petal. Order is determinate — `stage_two_action_set` is \ + the flat list chained with the meaningful sacrifice-mana actions — and the penalty is \ + `Sacrifices` because `mana_ability_penalty`'s FIRST clause is `cost_includes_sacrifice`, \ + which inspects `Composite` legs" + ); + + let (accept_probe, accept_flat) = engine::ai_support::shortcut_probe(&accept_arm, P2); + assert_eq!( + engine::ai_support::stage_two_action_set(accept_probe.state(), &accept_flat), + accept_flat, + "the negative half of the widening, on the same instrument: without the Petal there is \ + nothing to re-admit" + ); + + for (label, arm) in [("ACCEPT", &accept_arm), ("SHORTEN", &shorten_arm)] { + assert!( + stage_one_meaningful(arm, P2), + "reach-guard ({label} arm): stage 1 must return true, or the seat answers at stage 1 \ + and the fold under test never runs" + ); + } + + assert_eq!( + engine::ai_support::smart_shortcut_response(&accept_arm, P2), + ShortcutResponse::Accept, + "the pair's negative arm — and it is the flagship's own verdict on the flagship's own \ + action set" + ); + assert_eq!( + engine::ai_support::smart_shortcut_response(&shorten_arm, P2), + ShortcutResponse::Shorten { at_iteration: 0 }, + "the pair's positive arm: an actor-owned sacrifice-for-mana activation is two board \ + events, not a confined own resource. Stage 1 re-admits it PRECISELY because the \ + sacrifice is board-changing, so classifying it as confined here contradicted the stage \ + that handed it over" + ); + + // ── FUNDING LEMMA (CR 117.1d + CR 601.2g), deliberately QUARANTINED ── + // + // These two clones NEVER reach `smart_shortcut_response`. Staging the probe + // spell into an arm above would put a `CastSpell` in the flat list whose own + // `Effect::DealDamage` reaches the fail-closed arm — the pair would then + // Shorten with the fix reverted and would stop measuring anything. The + // separation IS the design; do not merge them. + // + // `{1}` generic on purpose: a generic residual is decided by comparing the + // summed capacity against it, so both halves are decided on one read path + // with zero slack. Without the Petal, P2's battlefield is one Terramorphic + // Expanse, whose ability heads `SearchLibrary` and contributes 0, so the sum + // is 0 < 1; with the Petal it is exactly 1 >= 1. + let mut funded = shorten_arm.clone(); + let probe_spell = give_bolt_with_cost(&mut funded, P2, ManaCost::generic(1)); + assert!( + probe_actions(&funded, P2).iter().any( + |a| matches!(a, GameAction::CastSpell { object_id, .. } if *object_id == probe_spell) + ), + "FUNDING: with the Petal on the battlefield the engine's own castability gate says a \ + {{1}} interaction IS payable — by activating a mana ability during cost payment, which \ + is exactly what happens inside the window this row buys (CR 117.1d / CR 601.2g); got \ + {:?}", + non_pass_actions(&funded, P2) + ); + + let mut unfunded = accept_arm.clone(); + let unfunded_spell = give_bolt_with_cost(&mut unfunded, P2, ManaCost::generic(1)); + assert!( + !probe_actions(&unfunded, P2).iter().any( + |a| matches!(a, GameAction::CastSpell { object_id, .. } if *object_id == unfunded_spell) + ), + "FUNDING, negative half: the SAME interaction on the SAME board minus the Petal is NOT \ + payable. Together with the assertion above, the Petal's mana is what makes it reachable \ + — which is the whole content of 'otherwise-unaffordable'; got {:?}", + non_pass_actions(&unfunded, P2) + ); + + // ── the ORDINARY-mana-source control: plain mana never leaks into stage 2 ── + let mut ordinary = polled.clone(); + let sol_ring = give_sol_ring(&mut ordinary, P2); + let (ordinary_probe, ordinary_flat) = engine::ai_support::shortcut_probe(&ordinary, P2); + // REACH-GUARD — the assertion the two negatives below rest on. `stage_two_action_set` + // filters `activatable_object_mana_actions`, and on a probe state parked at + // `Priority { player }` that IS + // `mana_sources::activatable_mana_actions_for_player(state, player)` — the same call, + // routed through `mana_action_player`'s `Priority` arm. Asserting the Sol Ring is IN + // that sweep is what makes "absent from the stage-2 set" attributable to the penalty + // filter; without it, the two negatives below cannot tell `ManaSourcePenalty::None` + // apart from "this object was never swept as a mana source at all". + assert!( + engine::game::mana_sources::activatable_mana_actions_for_player(ordinary_probe.state(), P2) + .contains(&GameAction::ActivateAbility { + source_id: sol_ring, + ability_index: 0, + }), + "REACH-GUARD: the Sol Ring must reach the sweep stage 2 filters, or the negatives \ + below pass because nothing was ever there to re-admit — which is not the \ + `None`-vs-`Sacrifices` penalty distinction this control claims to measure" + ); + assert!( + !ordinary_flat.iter().any( + |a| matches!(a, GameAction::ActivateAbility { source_id, .. } if *source_id == sol_ring) + ), + "candidate generation excludes a mana ability outright (CR 605.1a); got {ordinary_flat:?}" + ); + assert_eq!( + engine::ai_support::stage_two_action_set(ordinary_probe.state(), &ordinary_flat), + ordinary_flat, + "NON-VACUITY: an ordinary mana source has penalty `None`, not `Sacrifices`, so the \ + stage-2 widening does NOT re-admit it. The Lotus Petal DOES enter this same set in this \ + same test, so this emptiness is a measurement and not a stuck instrument" + ); + // NO verdict assertion here, deliberately. Sol Ring contributes 2 to + // `feasible_mana_capacity`, which is enough to pay Angel of the Ruins' + // hand-zone plainscycling already sitting in P2's hand on this fixture. That + // activation enters the FLAT list — MEASURED, because the withheld verdict + // rests on it and a stale fixture could silently make it false: + let ordinary_non_pass = non_pass_actions(&ordinary, P2); + assert!( + ordinary_non_pass.iter().any(|a| { + a.contains("Angel of the Ruins") + && a.contains("#0 zone=Some(Hand)") + && a.contains("controller=Some(PlayerId(2))") + }), + "the Sol Ring's 2 mana must unlock the Angel's {{2}} plainscycling — this is the fact \ + that makes withholding a verdict assertion correct rather than evasive. Ability index 0 \ + is MEASURED off this fixture, never assumed: the Angel (object 210) carries EXACTLY ONE \ + parsed ability and it is the plainscycling one — `ability_tag: Cycling`, \ + `activation_zone: Hand`, cost `Composite[Mana{{generic 2}}, Discard{{self_ref}}]`, \ + effect `SearchLibrary` filtered to `Subtype(Plains)`. A bare name match would also be \ + satisfied by some OTHER Angel activated ability, or by an Angel in a zone whose \ + activation this Sol Ring does not pay for, neither of which supports the withheld \ + verdict. If this reddens, first check whether the INDEX shifted (a newly parsed second \ + ability) before concluding the semantics changed; got {ordinary_non_pass:?}" + ); + // It is `MayInterfere` on two independent legs (`AbilityCost::Discard` is not + // allowlisted; its `sub_ability` moves a card to a HAND, which the anaphoric + // fetch disjunct does not cover), so this seat answers Shorten — through + // candidate affordability, a route this control does not model and claims + // nothing about. Asserting Accept here would assert a false fact about the + // board. +} + +/// V10c — an UNTAPPED fetch is the `Effect::Mana` case with one extra step, so +/// the seat must keep its window; the TAPPED sibling still Accepts. +/// +/// `v10a`/`v10b` closed mana production. This row closes the residual one arm +/// over: `effect_window_reach`'s `ChangeZone` arm allowlisted ANY +/// `Library -> Battlefield` move, and a land that arrives untapped (CR 110.5b — +/// permanents enter untapped "unless a spell or ability says otherwise") taps for +/// mana inside the window the Shorten hands back. CR 302.6's summoning-sickness +/// bar is a CREATURE rule and never reaches a land, and CR 601.2g runs the mana +/// ability during the cast it funds. +/// +/// **The pair varies exactly one object** on the flagship board: a Crop Rotation +/// in P2's hand. Its four-legged AST is confined on every axis but the tap state +/// (MEASURED — see `CROP_ROTATION`), so the Shorten below is attributable to the +/// gate and to nothing else. +/// +/// **Otherwise-unaffordable, both halves, on the production instrument.** The +/// negative half is asserted in BOTH arms at poll time: the `{1}` answer is not +/// castable, because `feasible_mana_capacity` is battlefield-scoped and P2's +/// battlefield is one Terramorphic Expanse (capacity 0). The positive half is the +/// QUARANTINED funding lemma: the Rotation is driven through the stack on the +/// `apply()` boundary, a real basic Swamp from P2's own recorded library enters +/// UNTAPPED, and the SAME answer is re-probed against the engine's own +/// castability gate. +/// +/// **The tapped control is the load-bearing half of the pair.** The same drive +/// with Rampant Growth puts a land on the battlefield TAPPED, the answer stays +/// uncastable, and the seat still Accepts. That is what makes this gate a +/// distinction rather than a blanket flip — and it is the measurement that keeps +/// `v1`/`v1b`'s Terramorphic Accept correct rather than merely surviving. +/// +/// MUTANT: in `effect_window_reach`'s `ChangeZone` arm, replace +/// `object_is_confined && entry_is_confined` with `object_is_confined` alone (the +/// pre-fix expression) — the SHORTEN arm flips to `Accept` (the shipped defect +/// this row exists to catch). The ACCEPT arm and +/// the tapped control are unaffected by that mutation by construction — neither +/// carries an untapped battlefield entry at all — so the row cannot pass by +/// trivializing in either direction. +/// +/// Every `GameAction::CastSpell` matcher binds `{ object_id, .. }` and must NOT +/// name `payment_mode`, for `v10a`'s reason. +#[test] +fn v10c_an_untapped_fetch_that_funds_an_unaffordable_answer_keeps_its_window() { + let mut board = live_path_board(); + drive_to_offer(&mut board, 400).expect("CR 732.2a: the offer must fire on this real 4p drain"); + let polled = declare_and_poll(&board, P2); + + let mut base = polled.clone(); + // `{1}` generic, sized as in `v10b`: a generic residual is decided by + // comparing summed battlefield capacity against it, so ONE untapped Swamp is + // exactly enough and one tapped Swamp is exactly not — zero slack on both + // halves, decided on one read path. + let answer = give_bolt_with_cost(&mut base, P2, ManaCost::generic(1)); + + // ── arm ACCEPT: the answer alone ── + let accept_arm = base.clone(); + // ── arm SHORTEN: same board, same answer, PLUS the untapped fetch ── + let mut shorten_arm = base.clone(); + let rotation = give_crop_rotation(&mut shorten_arm, P2); + + for (label, arm) in [("ACCEPT", &accept_arm), ("SHORTEN", &shorten_arm)] { + assert!( + !probe_actions(arm, P2).iter().any( + |a| matches!(a, GameAction::CastSpell { object_id, .. } if *object_id == answer) + ), + "PREMISE ({label} arm): the answer must be OTHERWISE-UNAFFORDABLE at poll time — P2's \ + battlefield is one Terramorphic Expanse, which contributes 0 to the \ + battlefield-scoped capacity scan, and the scan cannot see the fetch-then-tap two-step \ + the window buys; got {:?}", + non_pass_actions(arm, P2) + ); + assert!( + stage_one_meaningful(arm, P2), + "reach-guard ({label} arm): stage 1 must return true, or the seat answers at stage 1 \ + and the fold under test never runs" + ); + } + + assert!( + probe_actions(&shorten_arm, P2).iter().any( + |a| matches!(a, GameAction::CastSpell { object_id, .. } if *object_id == rotation) + ), + "reach-guard: the FUNDER must really be castable, or the SHORTEN arm is the ACCEPT arm \ + with extra steps; got {:?}", + non_pass_actions(&shorten_arm, P2) + ); + + let shorten_non_pass = non_pass_actions(&shorten_arm, P2); + assert_eq!( + shorten_non_pass.len(), + 2, + "ATTRIBUTION + THRESHOLD SENTINEL, mirroring `v10a`'s: the SHORTEN arm must be the ACCEPT \ + arm's single fetchland PLUS the Rotation cast, and nothing else. A fixture or capacity \ + change that added a third `MayInterfere` action would over-determine this row silently \ + instead of reddening; got {shorten_non_pass:?}" + ); + + let accept_non_pass = non_pass_actions(&accept_arm, P2); + assert_eq!( + accept_non_pass.len(), + 1, + "ATTRIBUTION: the ACCEPT arm's action set must be the flagship's exactly, so its Accept is \ + the already-shipped verdict and the pair's ONLY variable is the Rotation; got \ + {accept_non_pass:?}" + ); + assert!( + accept_non_pass[0].contains("Terramorphic Expanse") + && accept_non_pass[0].contains("zone=Some(Battlefield)") + && accept_non_pass[0].contains("controller=Some(PlayerId(2))"), + "ATTRIBUTION: that one action must be P2's OWN battlefield fetchland; got \ + {accept_non_pass:?}" + ); + + // MEMBERSHIP, not just cardinality — `v10a`'s partition, for its reasons. + let (_rotation_leg, other_legs): (Vec<&String>, Vec<&String>) = shorten_non_pass + .iter() + .partition(|a| a.starts_with(&format!("CastSpell {{ object_id: {rotation:?},"))); + assert_eq!( + other_legs, + vec![&accept_non_pass[0]], + "ATTRIBUTION: the SHORTEN arm's set MINUS the Rotation must be the ACCEPT arm's set \ + EXACTLY — same fetchland object, same zone, same controller; got {shorten_non_pass:?}" + ); + + assert_eq!( + engine::ai_support::smart_shortcut_response(&accept_arm, P2), + ShortcutResponse::Accept, + "the pair's negative arm: an unaffordable answer and a confined TAPPED fetchland buy \ + nothing" + ); + assert_eq!( + engine::ai_support::smart_shortcut_response(&shorten_arm, P2), + ShortcutResponse::Shorten { at_iteration: 0 }, + "the pair's positive arm: a fetch that puts a land onto the battlefield UNTAPPED \ + (CR 110.5b) hands the seat mana inside its own window, so accepting here surrenders a \ + live out. The Rotation is the one object that differs" + ); + + // ── FUNDING LEMMA (CR 110.5b + CR 117.1d + CR 601.2g), QUARANTINED ── + // + // Both verdicts above are already taken; resolving the fetch inside an arm + // would change the very action set they were taken on, and the funded board + // additionally unlocks the Angel's `{2}` cycling (`give_lotus_petal`'s + // capacity note), which carries `MayInterfere` on a route this pair does not + // model. Same quarantine as `v10a`/`v10b`. + let (funded, fetched) = resolve_fetch_choosing_a_swamp(&shorten_arm, rotation); + let land = funded + .objects + .get(&fetched) + .expect("the fetched Swamp is a real object on this board"); + assert_eq!( + land.zone, + Zone::Battlefield, + "reach-guard: the Rotation must have RESOLVED and MOVED the land, not merely been cast — \ + otherwise the funding assertion below would red for the wrong reason" + ); + assert!( + !land.tapped, + "CR 110.5b: the fetched land enters UNTAPPED because this fetch says nothing otherwise. \ + This is the game-level fact the AST's `enter_tapped` stands for, measured on the object \ + rather than inferred from the printed text" + ); + assert!( + probe_actions(&funded, P2) + .iter() + .any(|a| matches!(a, GameAction::CastSpell { object_id, .. } if *object_id == answer)), + "FUNDING: with the fetched Swamp untapped, the engine's own castability gate says the SAME \ + `{{1}}` answer that is unaffordable in BOTH arms above IS payable. That is the two-step \ + the priority window buys (CR 117.1d / CR 601.2g), measured rather than inferred; got {:?}", + non_pass_actions(&funded, P2) + ); + + // ── THE TAPPED CONTROL — the half that makes this a distinction ── + // + // Same board, same answer, same drive, same chosen Swamp: only the fetch's + // printed tap rider differs. The land arrives tapped, funds nothing, and the + // seat still Accepts — which is `v1`/`v1b`'s Terramorphic verdict, measured + // here on the funding mechanism itself instead of assumed to survive. + let mut tapped_arm = base.clone(); + let growth = give_rampant_growth(&mut tapped_arm, P2); + assert_eq!( + non_pass_actions(&tapped_arm, P2).len(), + 2, + "reach-guard: the tapped fetch must be castable too, or its Accept below is produced by an \ + absent action rather than by a confined one; got {:?}", + non_pass_actions(&tapped_arm, P2) + ); + + let (tapped_funded, tapped_fetched) = resolve_fetch_choosing_a_swamp(&tapped_arm, growth); + let tapped_land = tapped_funded + .objects + .get(&tapped_fetched) + .expect("the fetched Swamp is a real object on this board"); + assert_eq!( + tapped_land.zone, + Zone::Battlefield, + "reach-guard: the tapped fetch must have resolved and moved the land as well" + ); + assert!( + tapped_land.tapped, + "CR 110.5b: 'unless a spell or ability says otherwise' — this one says otherwise, and this \ + assertion is what separates the pair" + ); + assert!( + !probe_actions(&tapped_funded, P2) + .iter() + .any(|a| matches!(a, GameAction::CastSpell { object_id, .. } if *object_id == answer)), + "FUNDING, negative half: a TAPPED land pays for nothing, so the identical answer stays \ + uncastable after the identical drive. Together with the assertion above, the tap state — \ + not the fetch shape — is what funds the out; got {:?}", + non_pass_actions(&tapped_funded, P2) + ); + assert_eq!( + engine::ai_support::smart_shortcut_response(&tapped_arm, P2), + ShortcutResponse::Accept, + "…and the classifier agrees with the board: a tapped fetch buys the seat nothing, so the \ + gate is a DISTINCTION on the tap axis and not a blanket flip of the fetch class. This is \ + the assertion an over-broad version of this fix destroys first" + ); +} + +// =========================================================================== +// PR #7101 — the two maintainer findings, both on the REAL 4p board. +// +// SHARED PREMISE, MEASURED once here rather than re-asserted per row. At the +// offer beat the flagship board carries 171 active trigger definitions; the +// CR 113.6 zone-of-function gate removes 168, leaving exactly three: +// +// Dina, Soul Steeper LifeGained valid_card=None owner=P0 +// Bloodthirsty Conqueror LifeLost valid_card=None owner=P0 +// Abundant Growth ChangesZone valid_card=SelfRef owner=P1 +// +// Dina and the Conqueror are relieved by the MODE gate (CR 119.3: no confined +// action adjusts a life total). Abundant Growth is relieved for P2 by the +// SELF-REFERENCE carve-out and by nothing else — `obj.owner (P1) != actor (P2)` +// is the whole of it. That is why `v1_live_path_*` still Accepts, and it is why +// polling P1 on this same board would find a live observer: for P1 the carve-out +// conjunct is false. P1 is nevertheless safe, and NOT because "no board fires" — +// it is because `smart_shortcut_response` returns `Accept` from STAGE 1, before +// `any_action_may_interfere` ever runs, which `v1_live_path_*`'s own sibling +// control pins via `!stage_one_meaningful(&other, seat)`. +// =========================================================================== + +const HEDRON_CRAB: &str = "Landfall — Whenever a land you control enters, target player mills \ + three cards. (They put the top three cards of their library into their \ + graveyard.)"; +const BLOODGHAST_LANDFALL: &str = "Landfall — Whenever a land you control enters, you may return \ + this card from your graveyard to the battlefield."; + +/// Stage a real parsed OBSERVER — a card whose rules content is a printed +/// TRIGGER rather than an activated/spell ability — owned and controlled by +/// `player`. +/// +/// Distinct from `give_parsed_card` above on purpose: that helper stages the +/// ACTING object and asserts exactly one parsed *ability*, which is the premise +/// the V10 rows need and the exact opposite of what an observer carries. Verbatim +/// Oracle text under the card's REAL name — a paraphrase can take a different +/// parser branch and green a row while the printed card stays broken. +fn give_parsed_observer( + state: &mut GameState, + player: PlayerId, + zone: Zone, + name: &str, + oracle: &str, +) -> ObjectId { + let parsed = engine::parser::oracle::parse_oracle_text(oracle, name, &[], &[], &[]); + assert_eq!( + parsed.triggers.len(), + 1, + "PREMISE: {name} must parse to exactly one printed trigger, or the row below cannot \ + attribute its verdict to that trigger; got {:?}", + parsed.triggers + ); + let id = engine::game::zones::create_object( + state, + CardId(state.next_object_id), + player, + name.to_string(), + zone, + ); + let obj = state.objects.get_mut(&id).expect("just created"); + obj.card_types.core_types.push(CoreType::Creature); + obj.base_card_types = obj.card_types.clone(); + obj.install_trigger_base_definitions(std::sync::Arc::new(parsed.triggers)) + .expect("staging one printed trigger set on a fresh object"); + state.layers_dirty = LayersDirty::full(); + id +} + +/// Every active trigger definition on `object`, rendered for assertions. +fn trigger_shapes(state: &GameState, object: ObjectId) -> Vec { + let obj = state.objects.get(&object).expect("staged object exists"); + engine::game::functioning_abilities::active_trigger_definitions(state, obj) + .map(|a| { + format!( + "mode={:?} valid_card={:?} trigger_zones={:?} zcc={}", + a.definition.mode, + a.definition.valid_card, + a.definition.trigger_zones, + a.definition.zone_change_clauses.len() + ) + }) + .collect() +} + +/// **T1.2 — the [MED] finding.** `SelfRef` / `Controller` prove CONTROL, never +/// OWNERSHIP. +/// +/// CR 110.2 makes owner and controller independent. CR 701.21a: "To sacrifice a +/// permanent, its controller moves it from the battlefield directly to its +/// OWNER'S graveyard." So when P2 cracks a Terramorphic Expanse it controls but +/// does NOT own, the land lands in somebody else's graveyard — a per-player zone +/// (CR 400.1) — and the action was never confined at all. The fold's +/// `filter_is_actor_owned(sacrifice.target)` says `true` regardless, because +/// `TargetFilter::SelfRef` resolves through control (CR 109.5). +/// +/// ONE VARIABLE against `v1_live_path_fetchland_seat_accepts_on_the_real_4p_board`: +/// the same board, the same beat, the same offer, the same single action — only +/// `ObjectId(203).owner` differs. MEASURED: all four seats own everything they +/// control on this dump naturally, so the violating shape has to be constructed; +/// it is constructed on the flagship's OWN fetchland rather than on a spare +/// object so that the mis-owned permanent IS the one being sacrificed. +/// +/// REVERT-PROBE (executed): trivialize `actor_owns_everything_they_control` to +/// `true` ⇒ this row returns `Accept` and fails. +#[test] +fn t1_2_a_controlled_but_unowned_fetchland_is_not_confined() { + let mut board = live_path_board(); + drive_to_offer(&mut board, 400).expect("the offer must fire"); + let mut polled = declare_and_poll(&board, P2); + + // REACH-GUARD, before the mutation: an `Accept` produced by an empty seat + // would be indistinguishable from an `Accept` produced by the predicate. + let non_pass = non_pass_actions(&polled, P2); + assert!( + !non_pass.is_empty(), + "REACH-GUARD: P2 must hold a non-pass action, or stage 1 answers and stage 2 never runs" + ); + assert!( + non_pass + .iter() + .any(|a| a.contains("Terramorphic Expanse") && a.contains("zone=Some(Battlefield)")), + "REACH-GUARD: the action under test must be the fetchland activation the finding names; \ + got {non_pass:?}" + ); + assert!( + stage_one_meaningful(&polled, P2), + "REACH-GUARD: stage 1 must still pass P2 through to stage 2" + ); + + let fetch = *polled + .objects + .values() + .find(|o| o.name == "Terramorphic Expanse" && o.controller == P2) + .map(|o| &o.id) + .expect("P2's fetchland is on this board"); + + // CONTROL half — untouched board, unchanged answer. + assert_eq!( + engine::ai_support::smart_shortcut_response(&polled, P2), + ShortcutResponse::Accept, + "matched control (T1.3): with ownership intact the seat still Accepts, so the flip below \ + is attributable to the owner field and to nothing else about this board" + ); + + // WITNESS half — one field. + polled.objects.get_mut(&fetch).expect("just read").owner = P1; + assert_eq!( + engine::ai_support::smart_shortcut_response(&polled, P2), + ShortcutResponse::Shorten { at_iteration: 0 }, + "CR 701.21a: sacrificing a permanent P2 controls but P1 OWNS puts a card into P1's \ + graveyard. `filter_is_actor_owned` cannot see that — it proves control (CR 109.5) — so \ + the ownership fact has to be proven at board level or this Accept is unsound" + ); +} + +/// **T2.1 — the [HIGH] finding.** A confined action is still OBSERVED. +/// +/// CR 603.2: "Whenever a game event or game state matches a triggered ability's +/// trigger event, that ability automatically triggers." Reading the ACTING +/// object's AST proves what that object does; it proves nothing about what the +/// rest of the board is watching for. Hedron Crab watches for exactly the event +/// the flagship's confined fetch produces, and its effect targets a PLAYER. +/// +/// The crab is OPPONENT-owned on purpose. Actor-owned, both the shipped +/// carve-out and the `filter_is_actor_owned` shape rejected in T2.3 would relieve +/// it (the `obj.owner != actor` conjunct rescues it), so an actor-owned crab +/// cannot tell the two apart. Only the opponent-owned one discriminates. +/// +/// REVERT-PROBE (executed): delete the `board_observer_may_react` conjunct from +/// `any_action_may_interfere` ⇒ this row returns `Accept` and fails. +#[test] +fn t2_1_an_opponent_owned_landfall_observer_defeats_a_confined_fetch() { + let mut board = live_path_board(); + drive_to_offer(&mut board, 400).expect("the offer must fire"); + + // MATCHED CONTROL (T2.2) — same board, same beat, no crab. + let clean = declare_and_poll(&board, P2); + assert!( + stage_one_meaningful(&clean, P2), + "REACH-GUARD: stage 1 passes P2 through, so both halves below measure stage 2" + ); + assert_eq!( + engine::ai_support::smart_shortcut_response(&clean, P2), + ShortcutResponse::Accept, + "T2.2 matched control: without an observer the confined fetch still Accepts" + ); + + // WITNESS — one object added, owned and controlled by an opponent. + let mut with_crab = board.clone(); + let crab = give_parsed_observer( + &mut with_crab, + P1, + Zone::Battlefield, + "Hedron Crab", + HEDRON_CRAB, + ); + + // PARSE PIN: if the parser ever stops producing this shape the row must go + // RED rather than silently green on a different (or absent) trigger. + let shapes = trigger_shapes(&with_crab, crab); + assert_eq!( + shapes.len(), + 1, + "PREMISE: the crab must carry exactly one active trigger; got {shapes:?}" + ); + assert!( + shapes[0].contains("mode=ChangesZone"), + "PREMISE: landfall is a CR 603.6a zone-change trigger; got {shapes:?}" + ); + assert!( + shapes[0].contains("Land") && shapes[0].contains("You"), + "PREMISE: `valid_card` must be the TYPED land-you-control filter, NOT `SelfRef`. This is \ + the exact shape T2.3 proves `filter_is_actor_owned` accepts — reusing that helper as the \ + carve-out would relieve this trigger and the [HIGH] finding would survive; got {shapes:?}" + ); + assert!( + shapes[0].contains("trigger_zones=[Battlefield]"), + "PREMISE: the crab must FUNCTION where it is standing, or the zone gate — not the \ + carve-out — would be what produces the verdict below; got {shapes:?}" + ); + + let polled = declare_and_poll(&with_crab, P2); + assert!( + stage_one_meaningful(&polled, P2), + "REACH-GUARD: the crab must not have changed which stage answers" + ); + assert_eq!( + engine::ai_support::smart_shortcut_response(&polled, P2), + ShortcutResponse::Shorten { at_iteration: 0 }, + "CR 603.2: P2's 'confined' fetch makes a land enter, the opponent's Crab triggers on it, \ + and its effect mills a TARGET PLAYER. The action reached outside P2's own resources \ + without P2's own AST containing anything that says so" + ); +} + +/// **T2.4 — the zone-of-function gate, on one axis.** Same card, same trigger, +/// two zones. +/// +/// CR 113.6 / CR 603.6: a Bloodghast-shaped landfall trigger functions from the +/// GRAVEYARD (that is where the ability returns the card from), so a copy sitting +/// in HAND observes nothing. The pair moves the zone and nothing else, so the +/// `Accept` cannot be a property of the card and the `Shorten` cannot be a +/// property of the board. +/// +/// REVERT-PROBE (executed): delete the `trigger_definition_functions_in_zone` +/// conjunct from `board_observer_may_react` ⇒ the hand half returns `Shorten` +/// and this row fails. +/// +/// SCOPE, measured rather than assumed. The OTHER way this gate can go wrong — +/// reading `def.trigger_zones.contains(&obj.zone)` directly, which answers +/// "functions nowhere" for the empty list that means battlefield-only — is NOT +/// discriminated by this row, and it cannot be discriminated on this board: +/// MEASURED, every active trigger definition on the flagship carries an explicit +/// `trigger_zones: [Battlefield]`, so the direct read and the authority agree on +/// the whole corpus. That fail-open direction is a LATENT hole here, closed for +/// the same reason as this module's `Zone::Stack` arm, and it is pinned by +/// `t2_4b_an_empty_trigger_zones_list_means_battlefield_not_nowhere` at unit +/// level where the shape can actually be built. +#[test] +fn t2_4_a_landfall_observer_in_hand_does_not_veto_but_in_its_own_zone_it_does() { + let mut board = live_path_board(); + drive_to_offer(&mut board, 400).expect("the offer must fire"); + + for (zone, expected) in [ + (Zone::Hand, ShortcutResponse::Accept), + ( + Zone::Graveyard, + ShortcutResponse::Shorten { at_iteration: 0 }, + ), + ] { + let mut staged = board.clone(); + let ghast = give_parsed_observer(&mut staged, P1, zone, "Bloodghast", BLOODGHAST_LANDFALL); + let shapes = trigger_shapes(&staged, ghast); + assert!( + shapes.iter().any(|s| s.contains("mode=ChangesZone")), + "PREMISE at {zone:?}: the landfall trigger must survive parsing; got {shapes:?}" + ); + + let polled = declare_and_poll(&staged, P2); + assert!( + stage_one_meaningful(&polled, P2), + "REACH-GUARD at {zone:?}: stage 2 must be the stage that answers" + ); + assert_eq!( + engine::ai_support::smart_shortcut_response(&polled, P2), + expected, + "CR 113.6: the SAME trigger on the SAME card must veto from the zone it functions in \ + and not from the zone it does not; failed at {zone:?}" + ); + } +} + +/// **T2.8 — the scan is not battlefield-only.** A command-zone emblem is scanned. +/// +/// CR 114.1 puts emblems in the command zone and CR 114.4 is exact — "Abilities +/// of emblems function in the command zone" — so an observer that never touches +/// the battlefield +/// still observes. `active_trigger_definitions` applies the CR 114.4 emblem gate +/// itself, which is why the scan delegates to it rather than filtering zones. +/// +/// REVERT-PROBE (executed): restrict `board_observer_may_react` to +/// `obj.zone == Zone::Battlefield` ⇒ this row returns `Accept` and fails. +#[test] +fn t2_8_a_command_zone_emblem_observer_is_scanned() { + let mut board = live_path_board(); + drive_to_offer(&mut board, 400).expect("the offer must fire"); + + let emblem = give_parsed_observer(&mut board, P1, Zone::Command, "Hedron Crab", HEDRON_CRAB); + { + let obj = board.objects.get_mut(&emblem).expect("just staged"); + obj.is_emblem = true; + // CR 114.1: an emblem has no characteristics other than its abilities. + obj.card_types.core_types.clear(); + obj.base_card_types = obj.card_types.clone(); + // CR 114.4: "Abilities of emblems function in the command zone." The + // printed crab trigger parses as battlefield-only, so the zone list is + // retargeted — that retarget is the whole staging, and the assertion + // below proves it survived into the live set. + let mut defs = (*obj.base_trigger_definitions).clone(); + for d in &mut defs { + d.trigger_zones = vec![Zone::Command]; + } + obj.install_trigger_base_definitions(std::sync::Arc::new(defs)) + .expect("re-staging the emblem's trigger set"); + } + let shapes = trigger_shapes(&board, emblem); + assert_eq!( + shapes.len(), + 1, + "PREMISE: the emblem must expose exactly one active trigger from the command zone — \ + `active_trigger_definitions` drops non-emblem command-zone triggers, so an empty list \ + here would make the row vacuous; got {shapes:?}" + ); + + let polled = declare_and_poll(&board, P2); + assert!( + stage_one_meaningful(&polled, P2), + "REACH-GUARD: stage 2 must be the stage that answers" + ); + assert_eq!( + engine::ai_support::smart_shortcut_response(&polled, P2), + ShortcutResponse::Shorten { at_iteration: 0 }, + "CR 114.1 + CR 603.2: an observer in the command zone observes. A battlefield-only scan \ + would miss every emblem and every command-zone trigger" + ); +} + +/// **T2.9 — the actor's OWN self-referential observer is NOT relieved**, on the +/// real board's own survivor. +/// +/// Abundant Growth (`ChangesZone`, `valid_card: SelfRef`) is one of the three +/// definitions that survive the zone gate on the flagship board, and for P2 it is +/// relieved by the carve-out's `obj.owner != actor` conjunct alone. Retag it to +/// P2 — owner AND controller together, so the T1.2 ownership conjunct stays +/// satisfied and cannot be what moves the verdict — and the same trigger must +/// stop being relieved. +/// +/// One variable: the object's seat. This is the row that pins the carve-out to +/// "somebody ELSE'S self-reference", which is the only version of it that is +/// sound: an actor's own self-referential trigger is reachable by the actor's own +/// action. +/// +/// REVERT-PROBE (executed): drop the `obj.owner != actor` conjunct ⇒ this row +/// returns `Accept` and fails. +#[test] +fn t2_9_the_actors_own_self_referential_observer_keeps_the_veto() { + let mut board = live_path_board(); + drive_to_offer(&mut board, 400).expect("the offer must fire"); + let mut polled = declare_and_poll(&board, P2); + + let growth = *polled + .objects + .values() + .find(|o| o.name == "Abundant Growth") + .map(|o| &o.id) + .expect("MEASURED: Abundant Growth is on this board and survives the zone gate"); + let shapes = trigger_shapes(&polled, growth); + assert!( + shapes + .iter() + .any(|s| s.contains("valid_card=Some(SelfRef)")), + "PREMISE: this row needs the SelfRef shape the carve-out keys on; got {shapes:?}" + ); + assert_ne!( + polled.objects[&growth].owner, P2, + "PREMISE: it starts on another seat, which is why the flagship Accepts" + ); + + // CONTROL — somebody else's self-reference, relieved. + assert_eq!( + engine::ai_support::smart_shortcut_response(&polled, P2), + ShortcutResponse::Accept, + "control: an opponent's SelfRef observer cannot be reached by an action confined to P2's \ + own resources" + ); + + // WITNESS — the same trigger, now the actor's own. + { + let obj = polled.objects.get_mut(&growth).expect("just read"); + obj.owner = P2; + obj.base_controller = Some(P2); + obj.controller = P2; + } + assert_eq!( + engine::ai_support::smart_shortcut_response(&polled, P2), + ShortcutResponse::Shorten { at_iteration: 0 }, + "CR 109.5: 'you' on the observer means ITS controller. Once that is the actor, the actor's \ + own confined action can reach it, so the carve-out must not apply" + ); +} + +/// **T3.5 — the new `GameObject::parse_warnings` field changes no committed byte.** +/// +/// `skip_serializing_if = "Vec::is_empty"` is the whole mechanism, and this is +/// the assertion that it is actually wired: re-serialize every object of a +/// committed fixture through the production decoder and compare against the +/// bytes the fixture shipped with. +/// +/// REVERT-PROBE (executed): drop `skip_serializing_if` from the field ⇒ every +/// object gains `"parse_warnings":[]` and this row fails. +#[test] +fn t3_5_the_new_parse_warnings_field_keeps_dumps_byte_identical() { + let json = gunzip_dump(include_bytes!("../fixtures/dina_noff_turn5_4p.json.gz")); + let envelope: serde_json::Value = + serde_json::from_str(&json).expect("dump envelope parses as JSON"); + let before = envelope["gameState"]["objects"].clone(); + assert!( + before.as_object().is_some_and(|m| !m.is_empty()), + "PREMISE: the fixture must carry objects, or byte-equality below is vacuous" + ); + assert!( + !before.to_string().contains("parse_warnings"), + "PREMISE: the committed OBJECTS predate the field, so any occurrence below is new. \ + (Scoped to `objects` on purpose: the envelope's card-database subtree carries the \ + face-level `parse_warnings` this field is copied FROM, and has since before this change.)" + ); + + let state = restore_dump(&json); + let after = + serde_json::to_value(state.objects.values().collect::>()).expect("objects reencode"); + assert!( + !after.to_string().contains("parse_warnings"), + "an EMPTY diagnostics list must not serialize. Without `skip_serializing_if` every object \ + in every committed dump gains a `\"parse_warnings\":[]` key and every stored game grows" + ); +} + +/// Helping Hand, verbatim (Oracle text verified on Scryfall, +/// `api.scryfall.com/cards/named?exact=Helping+Hand`). Confined on every axis +/// `effect_window_reach`'s `ChangeZone` arm reads EXCEPT the origin: the card it +/// returns is a real object in the actor's graveyard whose rules content the fold +/// never classified. +const HELPING_HAND: &str = "Return target creature card with mana value 3 or less from your \ + graveyard to the battlefield tapped."; + +/// Stage a vanilla creature card in `player`'s graveyard. VANILLA on purpose: +/// with no abilities of its own it cannot itself move any verdict — not through +/// `board_observer_may_react` (nothing to observe with), not through the fold +/// (it is not the subject of any action). It exists only so the Helping Hand +/// below has a legal target, and it is staged in BOTH arms so it is held +/// constant rather than varied. +fn give_graveyard_creature(state: &mut GameState, player: PlayerId) -> ObjectId { + let id = engine::game::zones::create_object( + state, + CardId(state.next_object_id), + player, + "Grizzly Bears".to_string(), + Zone::Graveyard, + ); + let obj = state.objects.get_mut(&id).expect("just created"); + obj.card_types.core_types.push(CoreType::Creature); + obj.base_card_types = obj.card_types.clone(); + obj.power = Some(2); + obj.toughness = Some(2); + state.layers_dirty = LayersDirty::full(); + id +} + +/// V10d — a TAPPED battlefield entry from a GRAVEYARD keeps the seat's window; +/// the tapped LIBRARY sibling still Accepts. +/// +/// `v10c` closed the tap axis on a library fetch. This row closes the ORIGIN +/// axis: `effect_window_reach`'s `ChangeZone` arm allowlisted a tapped +/// battlefield entry from ANY origin, so a graveyard recursion read as confined +/// while the card it returns IS in `state.objects` with readable +/// trigger/replacement/static definitions that nothing reads. +/// `object_window_reach`'s `carries_unreadable_rules_content` gate runs on the +/// SOURCE (the Helping Hand), never on the card it returns, and +/// `board_observer_may_react` correctly answers "does not function" for a +/// graveyard ETB (CR 113.6). So a Fleshbag-Marauder-class creature in the actor's +/// own graveyard — "When this creature enters, each player sacrifices a creature +/// of their choice" — arrives inside the very window the seat declined to keep. +/// +/// The LIBRARY sibling below is not confined because its origin excuses it from +/// being read — v10e is the row that shows a library fetch reading `Shorten` on +/// this same board — but because `library_arrivals_are_inert` reads the cards +/// Rampant Growth could actually select and finds them vanilla. +/// +/// **The pair varies exactly one object** on the flagship board, and both halves +/// of that object are the SAME SHAPE on every axis but origin: two spells staged +/// identically (`give_parsed_card`, instant, no printed cost), each heading a +/// `ChangeZone` to the battlefield with `enter_tapped: Tapped`, no +/// `enters_attacking`, no `enters_modified_if`, and a You-controlled target. The +/// graveyard creature is present in BOTH arms. So the Shorten is attributable to +/// the origin field and to nothing else. +/// +/// **The control is the load-bearing half.** Rampant Growth's tapped LIBRARY +/// fetch must still Accept — that is what keeps this gate a distinction rather +/// than a blanket flip, and it is the same measurement that keeps `v1`/`v1b`'s +/// Terramorphic Accept correct. +/// +/// **Conservative by construction, and the cost is stated.** Grizzly Bears is +/// vanilla, so this seat surrenders its window over a recursion that could not +/// have interfered. That is the gate's shape: it reads the ORIGIN, not the +/// returned card, so it is uniform over what the graveyard happens to hold. Per +/// the module's §2 that is the direction that costs efficacy rather than games, +/// and it is the same trade the `Hand` destination gate already made. MEASURED on +/// `data/card-data.json`, of the 493 document-wide tapped/non-attacking/ +/// unconditional/You-controlled battlefield entries, 247 come from a library and +/// keep their classification; 74 graveyard, 25 hand, 6 exile and 140 +/// absent-origin nodes lose it. +/// +/// MUTANT: in `effect_window_reach`'s `ChangeZone` arm, delete the +/// `*origin == Some(Zone::Library)` conjunct from `entry_is_confined` (the +/// pre-fix expression) — the SHORTEN arm flips to `Accept`. The ACCEPT arm is +/// unaffected by that mutation by construction: its origin already IS +/// `Library`, so the conjunct it deletes was true there anyway. +/// +/// Every `GameAction::CastSpell` matcher binds `{ object_id, .. }` and must NOT +/// name `payment_mode`, for `v10a`'s reason. +#[test] +fn v10d_a_tapped_graveyard_return_keeps_its_window_and_the_library_sibling_accepts() { + let mut board = live_path_board(); + drive_to_offer(&mut board, 400).expect("CR 732.2a: the offer must fire on this real 4p drain"); + let polled = declare_and_poll(&board, P2); + + let mut base = polled.clone(); + give_graveyard_creature(&mut base, P2); + + // ── arm ACCEPT: the tapped LIBRARY fetch ── + let mut accept_arm = base.clone(); + let growth = give_rampant_growth(&mut accept_arm, P2); + // ── arm SHORTEN: same board, same graveyard, the tapped GRAVEYARD return ── + let mut shorten_arm = base.clone(); + let helping_hand = give_parsed_card( + &mut shorten_arm, + P2, + "Helping Hand", + HELPING_HAND, + CoreType::Instant, + Zone::Hand, + ); + + for (label, arm, staged) in [ + ("ACCEPT", &accept_arm, growth), + ("SHORTEN", &shorten_arm, helping_hand), + ] { + assert!( + stage_one_meaningful(arm, P2), + "reach-guard ({label} arm): stage 1 must return true, or the seat answers at stage 1 \ + and the fold under test never runs" + ); + assert!( + probe_actions(arm, P2).iter().any( + |a| matches!(a, GameAction::CastSpell { object_id, .. } if *object_id == staged) + ), + "reach-guard ({label} arm): the staged spell must really be castable at this window, \ + or the arm is the base board with extra steps and the pair measures nothing; got {:?}", + non_pass_actions(arm, P2) + ); + } + + // ATTRIBUTION: both arms must be the flagship's action set PLUS exactly the + // one staged cast. A third `MayInterfere` action in either arm would + // over-determine the row silently instead of reddening. + let accept_non_pass = non_pass_actions(&accept_arm, P2); + let shorten_non_pass = non_pass_actions(&shorten_arm, P2); + assert_eq!( + accept_non_pass.len(), + 2, + "ATTRIBUTION: the ACCEPT arm must be the flagship fetchland PLUS the Rampant Growth cast, \ + and nothing else; got {accept_non_pass:?}" + ); + assert_eq!( + shorten_non_pass.len(), + 2, + "ATTRIBUTION: the SHORTEN arm must be the flagship fetchland PLUS the Helping Hand cast, \ + and nothing else; got {shorten_non_pass:?}" + ); + + // MEMBERSHIP, not just cardinality: the two arms MINUS their staged cast must + // be the same single flagship action, so the pair really does vary one object. + let accept_rest: Vec<&String> = accept_non_pass + .iter() + .filter(|a| !a.starts_with(&format!("CastSpell {{ object_id: {growth:?},"))) + .collect(); + let shorten_rest: Vec<&String> = shorten_non_pass + .iter() + .filter(|a| !a.starts_with(&format!("CastSpell {{ object_id: {helping_hand:?},"))) + .collect(); + assert_eq!( + accept_rest, shorten_rest, + "ATTRIBUTION: each arm's set MINUS its staged cast must be the SAME flagship action — same \ + object, same zone, same controller; got {accept_non_pass:?} vs {shorten_non_pass:?}" + ); + assert!( + accept_rest.len() == 1 + && accept_rest[0].contains("Terramorphic Expanse") + && accept_rest[0].contains("zone=Some(Battlefield)") + && accept_rest[0].contains("controller=Some(PlayerId(2))"), + "ATTRIBUTION: that shared action must be P2's OWN battlefield fetchland; got \ + {accept_rest:?}" + ); + + assert_eq!( + engine::ai_support::smart_shortcut_response(&accept_arm, P2), + ShortcutResponse::Accept, + "the pair's negative arm: a tapped LIBRARY fetch stays confined — Rampant Growth searches \ + for a BASIC land and every basic in P2's recorded library is vanilla, so \ + `library_arrivals_are_inert` really did read the selectable set rather than skip it \ + (v10e is the row that varies that set)" + ); + assert_eq!( + engine::ai_support::smart_shortcut_response(&shorten_arm, P2), + ShortcutResponse::Shorten { at_iteration: 0 }, + "the pair's positive arm: the returned card is a REAL object in the actor's graveyard \ + whose rules content this fold never classified, so `OwnResourcesOnly` would be a proof it \ + cannot discharge. The Helping Hand is the one object that differs" + ); +} + +/// V10e — a tapped LIBRARY fetch whose search can SELECT an interfering +/// permanent is not confined, and the two things that could be producing that +/// verdict are separated by two orthogonal one-variable controls. +/// +/// The hole this closes was argued, not overlooked. Every earlier revision of +/// `effect_window_reach`'s `ChangeZone` arm discharged the arriving card with a +/// hidden-zone argument: CR 400.2 makes a library hidden, the card is an unchosen +/// member of it, so "the seam is handed a `GameState` and a list of +/// `GameAction`s, and the card is the subject of neither". CR 701.23a is the rule +/// that refutes it — "To search for a card in a zone, look at all cards in that +/// zone (even if it's a hidden zone) and find a card that matches the given +/// description" — and MEASURED on THIS fixture at THIS poll, all 91 cards of P2's +/// library resolve in `state.objects` with full parsed rules content. Hidden +/// means the opponents cannot see it. The searcher picks whichever member they +/// like. +/// +/// **`board_observer_may_react` cannot cover this**, and correctly so: CR 113.6, +/// through `trigger_definition_functions_in_zone`, answers "does not function" +/// for a card in a library. That scan asks which triggers function NOW; the +/// hazard is a trigger that functions once the search puts the card onto the +/// battlefield. +/// +/// **The witness is on the recorded board, not staged.** P2's own library holds +/// **Bojuka Bog** — "This land enters tapped. / When this land enters, exile +/// target player's graveyard. / {T}: Add {B}." (Oracle text verified on Scryfall) +/// — a LAND whose ETB exiles a graveyard belonging to somebody else. A seat that +/// Accepts while holding an unrestricted land fetch has declined a window in +/// which it could have made a live cross-player choice. +/// +/// **Three arms, two orthogonal controls, one variable each.** +/// * `SHORTEN` — Reshape the Earth (`Typed[Land]`) on the recorded library. +/// * `ACCEPT_BY_FILTER` — Rampant Growth (`Typed[Land] + Basic`) on the SAME +/// library. Holds the board fixed and varies the printed search filter, so the +/// gate is shown to read the filter and not merely "some land fetch exists". +/// * `ACCEPT_BY_LIBRARY` — Reshape the Earth again, with the non-inert LANDS +/// removed from P2's library. Holds the card fixed and varies the deck, which +/// is the axis the gate actually claims to read. This is the arm that +/// distinguishes the implemented gate from a filter-text heuristic: a +/// `Basic`-means-safe shortcut passes `ACCEPT_BY_FILTER` and fails here. +/// +/// MUTANT (executed): delete the `library_arrivals_are_inert` conjunct from +/// `object_window_reach` ⇒ the `SHORTEN` arm flips to `Accept`. The two ACCEPT +/// arms are unaffected by that mutation by construction — their selectable sets +/// are already inert, so the deleted conjunct was true in both. +#[test] +fn v10e_an_unrestricted_land_fetch_that_can_select_bojuka_bog_keeps_its_window() { + let mut board = live_path_board(); + drive_to_offer(&mut board, 400).expect("CR 732.2a: the offer must fire on this real 4p drain"); + let polled = declare_and_poll(&board, P2); + + // ── PREMISE, read off the recorded library rather than assumed ── + let library: Vec = polled + .players + .iter() + .find(|p| p.id == P2) + .expect("P2 is seated on this dump") + .library + .iter() + .copied() + .collect(); + assert!( + library.len() > 20, + "PREMISE: P2 must have a real recorded library for the selection set to mean anything; \ + got {}", + library.len() + ); + let resolved = library + .iter() + .filter(|id| polled.objects.contains_key(id)) + .count(); + assert_eq!( + resolved, + library.len(), + "PREMISE, and the refutation of the hidden-zone argument this row exists to retire: every \ + library card must resolve in `state.objects` at the decision point. If this ever fails, \ + the gate has nothing to read and the row measures nothing" + ); + + let named = |state: &GameState, id: &ObjectId| { + state + .objects + .get(id) + .map(|o| o.name.clone()) + .unwrap_or_default() + }; + // A land the gate must reject, and the basics it must accept — both real + // members of this deck. + let bog = library + .iter() + .copied() + .find(|id| named(&polled, id) == "Bojuka Bog") + .expect("PREMISE: the recorded library must contain Bojuka Bog — it IS the hazard"); + let bog_obj = polled.objects.get(&bog).expect("just found"); + assert!( + !bog_obj.trigger_definitions.is_empty(), + "PREMISE: Bojuka Bog must carry its ETB trigger, or it is not the card this row names" + ); + assert!( + bog_obj.card_types.core_types.contains(&CoreType::Land), + "PREMISE: it must be a LAND, or an unrestricted land search cannot select it" + ); + assert!( + !bog_obj.card_types.supertypes.contains(&Supertype::Basic), + "PREMISE: it must NOT be basic, or the ACCEPT_BY_FILTER control below cannot be a control" + ); + let basics: Vec = library + .iter() + .copied() + .filter(|id| { + polled.objects[id] + .card_types + .supertypes + .contains(&Supertype::Basic) + }) + .collect(); + assert!( + !basics.is_empty(), + "PREMISE: ACCEPT_BY_FILTER needs a NON-EMPTY basic-land match set — an empty match set is \ + the one case `library_arrivals_are_inert` deliberately fails closed on, so it would \ + produce the wrong verdict for the wrong reason" + ); + for id in &basics { + let o = &polled.objects[id]; + assert!( + o.trigger_definitions.is_empty() + && o.replacement_definitions.is_empty() + && o.static_definitions.is_empty() + && o.keywords.is_empty(), + "PREMISE: every basic in this deck must be vanilla, or ACCEPT_BY_FILTER would be \ + asserting the wrong direction; {} is not", + o.name + ); + } + + // ── the three arms ── + let mut shorten_arm = polled.clone(); + let reshape_shorten = give_parsed_card( + &mut shorten_arm, + P2, + "Reshape the Earth", + RESHAPE_THE_EARTH, + CoreType::Instant, + Zone::Hand, + ); + + let mut accept_by_filter = polled.clone(); + let growth = give_rampant_growth(&mut accept_by_filter, P2); + + // Vary the DECK, not the card: strip every library land the gate would + // reject, leaving a pool whose land members are all vanilla. + let mut accept_by_library = polled.clone(); + let stripped = strip_non_inert_library_lands(&mut accept_by_library, P2); + assert!( + stripped.contains(&bog), + "the ACCEPT_BY_LIBRARY arm must actually remove the hazard, or it is the SHORTEN arm with \ + extra steps; removed {stripped:?}" + ); + let reshape_accept = give_parsed_card( + &mut accept_by_library, + P2, + "Reshape the Earth", + RESHAPE_THE_EARTH, + CoreType::Instant, + Zone::Hand, + ); + + // ── reach-guards: each arm's staged spell must really be castable here, and + // each arm must be the flagship action set PLUS exactly that one cast ── + for (label, arm, staged) in [ + ("SHORTEN", &shorten_arm, reshape_shorten), + ("ACCEPT_BY_FILTER", &accept_by_filter, growth), + ("ACCEPT_BY_LIBRARY", &accept_by_library, reshape_accept), + ] { + assert!( + stage_one_meaningful(arm, P2), + "reach-guard ({label}): stage 1 must return true, or the seat answers at stage 1 and \ + the fold under test never runs" + ); + let non_pass = non_pass_actions(arm, P2); + assert_eq!( + non_pass.len(), + 2, + "ATTRIBUTION ({label}): the arm must be the flagship fetchland PLUS the one staged \ + cast, and nothing else; got {non_pass:?}" + ); + assert!( + non_pass + .iter() + .any(|a| a.starts_with(&format!("CastSpell {{ object_id: {staged:?},"))), + "reach-guard ({label}): the staged spell must be castable at this window; got \ + {non_pass:?}" + ); + assert!( + non_pass.iter().any(|a| a.contains("Terramorphic Expanse") + && a.contains("zone=Some(Battlefield)") + && a.contains("controller=Some(PlayerId(2))")), + "ATTRIBUTION ({label}): the other action must be P2's OWN battlefield fetchland, so \ + all three arms share it; got {non_pass:?}" + ); + } + + assert_eq!( + engine::ai_support::smart_shortcut_response(&shorten_arm, P2), + ShortcutResponse::Shorten { at_iteration: 0 }, + "CR 701.23a: an unrestricted land search LOOKS AT ALL CARDS in the library, and this one \ + contains Bojuka Bog, whose ETB exiles a graveyard P2 does not own. Accepting here \ + declines a window holding a live cross-player choice" + ); + assert_eq!( + engine::ai_support::smart_shortcut_response(&accept_by_filter, P2), + ShortcutResponse::Accept, + "CONTROL 1 (filter varies, deck held): a basic-land search on this same library can select \ + only vanilla Plains and Swamps, so the gate is a distinction and not a blanket flip" + ); + assert_eq!( + engine::ai_support::smart_shortcut_response(&accept_by_library, P2), + ShortcutResponse::Accept, + "CONTROL 2 (deck varies, card held): the SAME unrestricted search is confined once every \ + land it could select is vanilla. This is what proves the gate reads the library rather \ + than the printed word 'basic'" + ); +} + +/// Remove from `player`'s library every LAND that carries rules content +/// `shortcut_efficacy`'s gate cannot classify, and return what was removed. +/// +/// Lands only, deliberately: the search under test selects lands, so stripping +/// the rest would vary more than the arm claims to. The predicate mirrors the +/// public half of `carries_unreadable_rules_content` — if the two ever disagree +/// the ACCEPT arm reds rather than passing quietly, because a survivor the gate +/// still rejects keeps the verdict at `Shorten`. +fn strip_non_inert_library_lands(state: &mut GameState, player: PlayerId) -> Vec { + let doomed: Vec = state + .players + .iter() + .find(|p| p.id == player) + .expect("player is seated") + .library + .iter() + .copied() + .filter(|id| { + state.objects.get(id).is_some_and(|o| { + o.card_types.core_types.contains(&CoreType::Land) + && (!o.trigger_definitions.is_empty() + || !o.replacement_definitions.is_empty() + || !o.static_definitions.is_empty() + || !o.keywords.is_empty()) + }) + }) + .collect(); + for id in &doomed { + state.objects.remove(id); + } + let library = &mut state + .players + .iter_mut() + .find(|p| p.id == player) + .expect("player is seated") + .library; + library.retain(|id| !doomed.contains(id)); + doomed +} diff --git a/crates/engine/tests/integration/specialize_runtime.rs b/crates/engine/tests/integration/specialize_runtime.rs index a2151b0f8a..52e88f5efb 100644 --- a/crates/engine/tests/integration/specialize_runtime.rs +++ b/crates/engine/tests/integration/specialize_runtime.rs @@ -41,6 +41,7 @@ fn specialize_back(name: &str, color: ManaColor, shard: ManaCostShard) -> BackFa casting_restrictions: vec![], casting_options: vec![], layout_kind: None, + parse_warnings: vec![], } } diff --git a/crates/engine/tests/integration/squirrel_perf_probe.rs b/crates/engine/tests/integration/squirrel_perf_probe.rs index 24fd2d1007..27edb9b8ec 100644 --- a/crates/engine/tests/integration/squirrel_perf_probe.rs +++ b/crates/engine/tests/integration/squirrel_perf_probe.rs @@ -1,7 +1,7 @@ //! One-off perf probe for the turn-40 squirrel board (Cryptolith Rite + ~700 //! Squirrel tokens). Loads a saved frontend game-state JSON and measures where //! the `legal_actions` clone-storm bites. Run with: -//! SQUIRREL_STATE=/path/to/state.json cargo test -p engine --test integration -- --nocapture --ignored squirrel_perf_probe +//! SQUIRREL_STATE=/path/to/state.json cargo test -p phase-engine --test integration -- --nocapture --ignored squirrel_perf_probe //! Diagnostic only; ignored by default so it never runs in CI. use std::time::Instant; diff --git a/crates/engine/tests/integration/std_s07_batch5b.rs b/crates/engine/tests/integration/std_s07_batch5b.rs index 8546922e4f..c49871ebf4 100644 --- a/crates/engine/tests/integration/std_s07_batch5b.rs +++ b/crates/engine/tests/integration/std_s07_batch5b.rs @@ -119,6 +119,7 @@ fn add_aang_dfc(state: &mut GameState) -> ObjectId { casting_restrictions: vec![], casting_options: vec![], layout_kind: None, + parse_warnings: vec![], }); id } diff --git a/crates/engine/tests/integration/stolen_goodies_zero_targets.rs b/crates/engine/tests/integration/stolen_goodies_zero_targets.rs index 38d4c24e0e..c00eff8605 100644 --- a/crates/engine/tests/integration/stolen_goodies_zero_targets.rs +++ b/crates/engine/tests/integration/stolen_goodies_zero_targets.rs @@ -80,6 +80,7 @@ fn stolen_goodies_can_be_cast_with_no_targets() { casting_restrictions: vec![], casting_options: vec![], layout_kind: None, + parse_warnings: vec![], }); let pool = &mut runner.state_mut().players[P0.0 as usize].mana_pool; diff --git a/crates/engine/tests/integration/tamiyo_inquisitive_student_flip.rs b/crates/engine/tests/integration/tamiyo_inquisitive_student_flip.rs index a0d4f4be7d..898a6cb4ab 100644 --- a/crates/engine/tests/integration/tamiyo_inquisitive_student_flip.rs +++ b/crates/engine/tests/integration/tamiyo_inquisitive_student_flip.rs @@ -113,6 +113,7 @@ fn tamiyo_third_draw_returns_transformed_not_stranded_in_exile() { casting_restrictions: vec![], casting_options: vec![], layout_kind: None, + parse_warnings: vec![], }); // Precondition: Tamiyo is on the battlefield, front-face (not transformed). diff --git a/crates/engine/tests/integration/wedding_announcement_transform.rs b/crates/engine/tests/integration/wedding_announcement_transform.rs index ecc56c4735..70fc1b1efe 100644 --- a/crates/engine/tests/integration/wedding_announcement_transform.rs +++ b/crates/engine/tests/integration/wedding_announcement_transform.rs @@ -97,6 +97,7 @@ fn wedding_announcement_human_branch_transforms_and_applies_festivity_anthem() { casting_restrictions: vec![], casting_options: vec![], layout_kind: None, + parse_warnings: vec![], }); wedding }; diff --git a/crates/server-core/src/protocol.rs b/crates/server-core/src/protocol.rs index dc5cc18763..724f871074 100644 --- a/crates/server-core/src/protocol.rs +++ b/crates/server-core/src/protocol.rs @@ -2085,16 +2085,40 @@ mod tests { #[test] fn server_message_draft_state_update_roundtrips() { use draft_core::types::*; - use draft_core::view::DraftPlayerView; - + use draft_core::view::{DraftPlayerView, DraftPoolGroups}; + + let first_pull = DraftCardInstance { + instance_id: "pack-1-card-1".to_string(), + name: "First Pull".to_string(), + set_code: "TST".to_string(), + collector_number: "1".to_string(), + rarity: "common".to_string(), + colors: vec!["W".to_string()], + cmc: 1, + type_line: "Creature — Test".to_string(), + }; + let second_pull = DraftCardInstance { + instance_id: "pack-2-card-1".to_string(), + name: "Second Pull".to_string(), + set_code: "TST".to_string(), + collector_number: "2".to_string(), + rarity: "uncommon".to_string(), + colors: vec!["U".to_string()], + cmc: 2, + type_line: "Instant".to_string(), + }; + let pool = vec![first_pull.clone(), second_pull.clone()]; + let pool_groups = DraftPoolGroups::from_pool(&pool); let view = DraftPlayerView { - status: DraftStatus::Drafting, - kind: DraftKind::Premier, + status: DraftStatus::Deckbuilding, + kind: DraftKind::Sealed, current_pack_number: 0, pick_number: 2, pass_direction: PassDirection::Left, current_pack: None, - pool: Vec::new(), + pool, + pool_groups, + sealed_packs: Some(vec![vec![first_pull], vec![second_pull]]), seats: Vec::new(), cards_per_pack: 14, pack_count: 3, @@ -2106,16 +2130,30 @@ mod tests { tournament_format: TournamentFormat::Swiss, pod_policy: PodPolicy::Competitive, pairings: Vec::new(), - match_config: DraftKind::Premier.match_config(), + match_config: DraftKind::Sealed.match_config(), }; let msg = ServerMessage::DraftStateUpdate { view: view.clone() }; let json = serde_json::to_string(&msg).unwrap(); let parsed: ServerMessage = serde_json::from_str(&json).unwrap(); match parsed { ServerMessage::DraftStateUpdate { view: v } => { - assert_eq!(v.status, DraftStatus::Drafting); + assert_eq!(v.status, DraftStatus::Deckbuilding); assert_eq!(v.pick_number, 2); assert_eq!(v.timer_remaining_ms, Some(5000)); + assert_eq!(v.pool_groups, view.pool_groups); + assert_eq!( + v.sealed_packs + .as_ref() + .expect("sealed packs survive JSON transport") + .iter() + .map(|pack| { + pack.iter() + .map(|card| card.instance_id.as_str()) + .collect::>() + }) + .collect::>(), + vec![vec!["pack-1-card-1"], vec!["pack-2-card-1"]] + ); } _ => panic!("wrong variant"), } diff --git a/docs/AI-CONTRIBUTOR.md b/docs/AI-CONTRIBUTOR.md index b590d35200..1e013c63e9 100644 --- a/docs/AI-CONTRIBUTOR.md +++ b/docs/AI-CONTRIBUTOR.md @@ -12,7 +12,7 @@ If you are the LLM executing this: read top to bottom and follow every step. Do Before Step 1, confirm the following. **Tool support** and **Autonomy** are hard requirements: without them, abort. **Model** is load-bearing — see §0.1 for tier routing; report your actual model accurately on a `Model:` line in the PR body. **Thinking level** is advisory. -- **Model (load-bearing, and now a hard gate):** §0.1 requires a **Frontier-tier** model; if you are not one, abort. Report your model on a single canonical line in the PR body (e.g. `Model: claude-opus-4-8`, `Model: gpt-5-5`, `Model: codex-5-5`). Do not editorialize this line — `/pr-contribution-handler` parses it (and the matching `Tier:` line in §0.1.4). **Report it accurately.** Misreporting your model to clear the gate is the one thing here that will get the account itself blocked rather than the PR closed: your commit trailers and output are legible to us, so a false `Model:` line is caught, and it converts an out-of-policy PR into a trust problem. +- **Model (load-bearing, and now a hard gate):** §0.1 requires a **Frontier-tier** model; if you are not one, abort. Report your model on a single canonical line in the PR body (e.g. `Model: claude-opus-4-8`, `Model: gpt-5-5`, `Model: codex-5-5`). If your harness never exposes an exact identifier, §0.1.1 gives you a hedged form to declare instead — that case is not an abort. Do not editorialize this line — `/pr-contribution-handler` parses it (and the matching `Tier:` line in §0.1.4). **Report it accurately.** Misreporting your model to clear the gate is the one thing here that will get the account itself blocked rather than the PR closed: your commit trailers and output are legible to us, so a false `Model:` line is caught, and it converts an out-of-policy PR into a trust problem. - **Thinking (advisory):** High or higher. On Claude Code this is available for Opus; on Codex CLI pass `--reasoning high` or higher. Report on a `Thinking:` line in the PR body. - **Tool support (required):** You can invoke skills, use `WebFetch`, run shell commands, and use an independent reviewer or fresh context when requested. Without these, you cannot run `$engine-implementer` and must abort. - **Autonomy (required):** You will not pause for human input during the run. Every decision fork defaults to the architecturally idiomatic path as defined by `CLAUDE.md`, `AGENTS.md`, and the skills under `.claude/skills/`. @@ -29,11 +29,21 @@ Skill references in this section use the `$skill` / `/skill` convention defined |----------|--------|-----------| | Frontier | **Anthropic:** `claude-opus-4-8`+, `claude-sonnet-5`+ · **OpenAI:** `gpt-5-5`+ · **Cursor/Codex:** `codex-5-5`+ | Full pipeline per §4 onward. | -**Frontier-tier models only.** There is no longer a Standard tier. The floor is per-vendor and is stated by exact model, not by family wildcard — `claude-sonnet-5` is accepted while `claude-sonnet-4-6` is not, so a `claude-sonnet-*` reading of this table is wrong. **Not accepted:** `claude-opus-4-7` and below, `claude-sonnet-4-6` and below, every `claude-haiku-*` including `claude-haiku-4-5`, every `composer-*`, `gpt-5-4` and below including `gpt-5-3`, and `codex-5-4` and below. If that is your model, abort per §0 rather than opening a PR. A PR declaring a non-Frontier model, or whose commits show one, will be closed as out-of-policy without an implementation review. This is not a judgement about those models generally; it reflects that review capacity here is the scarce resource, and sub-Frontier runs have consistently consumed several maintainer rounds per PR to reach a standard a Frontier run reaches on the first pass. +**Frontier-tier models only.** There is no longer a Standard tier. The floor is per-vendor and is stated by exact model, not by family wildcard — `claude-sonnet-5` is accepted while `claude-sonnet-4-6` is not, so a `claude-sonnet-*` reading of this table is wrong (but a *newer* version than the one named does qualify — see the reading rule below). **Not accepted:** `claude-opus-4-7` and below, `claude-sonnet-4-6` and below, every `claude-haiku-*` including `claude-haiku-4-5`, every `composer-*`, `gpt-5-4` and below including `gpt-5-3`, and `codex-5-4` and below. If that is your model, abort per §0 rather than opening a PR. A PR declaring a non-Frontier model, or whose commits show one, will be closed as out-of-policy without an implementation review. This is not a judgement about those models generally; it reflects that review capacity here is the scarce resource, and sub-Frontier runs have consistently consumed several maintainer rounds per PR to reach a standard a Frontier run reaches on the first pass. **How commit evidence is read.** The gate is about the model that *wrote the change*, so a `Co-Authored-By:` trailer is read against the commits that carry the implementation. A session that starts on a Frontier model and falls back to a sub-Frontier one part-way through — a usage limit, a harness default — leaves sub-Frontier trailers on later commits without the PR having been generated below the floor. That is a fixable declaration problem, not dishonesty: expect to be asked to confirm which model did the work. What earns a close is the whole run sitting below the floor; what escalates to an account-level problem is a `Model:`/`Tier:` line that contradicts the trailers in a direction that clears the gate. -If you cannot determine your model, abort — do not guess and do not proceed on the assumption that you qualify. Tier cannot satisfy the artifact gate or authorize architecture scope. +A trailer that names a *harness* rather than a model — `Copilot`, `Cursor`, and similar — neither corroborates nor contradicts the declaration, because those harnesses never expose the underlying model to the trailer. Its silence is not evidence of misreporting and is not grounds for a close; ask which model did the work if it matters. Only a trailer naming an actual sub-floor model can contradict a declaration. + +**Reading the table — `+` means that version or anything later.** `claude-sonnet-5`+ admits `claude-sonnet-5` and every later version in that same family from that vendor, so a model that postdates the last edit of this table qualifies without being enumerated. Compare versions after normalizing separators — `gpt-5.6` and `gpt-5-6` are the same version — and treat a vendor variant suffix (`-sol`, `-thinking`, `-preview`, a date stamp) as still inside its family: `gpt-5.6-sol` sits above the `gpt-5-5` floor and qualifies. The "not a family wildcard" rule points *downward* only: it forbids reading `claude-sonnet-*` as admitting `claude-sonnet-4-6`, which sits below the floor. Do not abort merely because your exact identifier is not printed above — normalize it and check it against the floor for your family instead. + +**If your harness does not expose an exact model identifier.** The gate is on capability, not on your ability to emit a canonical id string. Several harnesses — GitHub Copilot, IDE assistants, and hosted chat UIs with a model picker — never hand the running model its own identifier. That is not a disqualification. Route as follows: + +- You can establish vendor, family, and enough version detail to place yourself at or above a floor in the table above → **proceed**, and declare the canonical id if you have one. +- You know which model the harness selected (its picker name, the name in your system context, or the name your user stated) but not a canonical id → **proceed**, and declare it in exactly that form on a single line: `Model: (via ; canonical id not exposed)`, e.g. `Model: gpt-5.6-sol (via GitHub Copilot; canonical id not exposed)`. `Tier: Frontier` still applies. +- You cannot establish vendor, family, and version at all, or the version you can establish sits below the floor → **abort** per §0. + +Do not guess upward. Report the name your harness reports; the hedged form above clears the gate on its own, so inventing a canonical id you were never given buys nothing and lands in the §0 misreporting case. Tier cannot satisfy the artifact gate or authorize architecture scope. **Applies to PRs opened on or after 2026-07-24.** Pull requests opened before that date are judged on their code, not their declared tier — the Standard tier was accepted policy when they were written, and a contributor who reported a Sonnet, Haiku, or Composer run accurately was following the rules as published. `claude-haiku-4-5` in particular was named in the Standard row of the tier table until 2026-07-24T02:03:45Z, so Haiku trailers on a PR opened before that are evidence of compliance, not of a violation. Do not close an older PR for a declaration that was correct when it was made. This grandfathering covers the declaration only: every other gate in this document applies to open PRs regardless of age. @@ -356,6 +366,8 @@ Method: /engine-implementer ## LLM Model: # Frontier tier only — see §0.1.1 + Tier: Frontier Thinking: @@ -459,13 +471,23 @@ appointment or a linked issue labeled `accepted`, stop instead of opening a PR. You are going to implement one Magic: The Gathering card in the phase-rs/phase repository end-to-end and open a pull request. Do not pause to ask me anything. -Requirements: Frontier-tier model REQUIRED — Claude Opus 4.7+, GPT-5.5+, or -Codex 5.5+ at high+ thinking. If your runtime is below that (Sonnet, Haiku, -Composer, or any older model), STOP and tell me rather than opening a PR; it +Requirements: Frontier-tier model REQUIRED — Claude Opus 4.8+, Claude Sonnet 5+, +GPT-5.5+, or Codex 5.5+ at high+ thinking. "+" means that version or anything +later, so a newer model than the ones named here qualifies; compare versions +ignoring separator style ("5.6" == "5-6"), and a variant suffix like "-sol" or +"-thinking" stays in its family (gpt-5.6-sol qualifies). If your runtime is +below that floor (Claude Sonnet 4.6 or older, any Haiku, any Composer, GPT-5.4 +or older, Codex 5.4 or older), STOP and tell me rather than opening a PR; it will be closed as out-of-policy. Report your actual model on a single canonical "Model:" line and the exact "Tier: Frontier" line in the PR body (e.g. -"Model: claude-opus-4-8"). Do NOT editorialize either line or overstate the -model. Hard requirements: you can +"Model: claude-opus-4-8"). +If your harness never exposes an exact model identifier (GitHub Copilot, IDE +assistants, and picker-based chat UIs typically do not), do NOT abort for that +reason alone — declare the name your harness reports, in the form +"Model: gpt-5.6-sol (via GitHub Copilot; canonical id not exposed)", and +keep "Tier: Frontier". Abort only if you cannot establish your vendor, family, +and version at all, or you know you are below the floor. Do NOT editorialize +either line or overstate the model. Hard requirements: you can invoke skills, run shell commands, and you will not pause for input. Steps: diff --git a/lobby-worker/broker-wasm/Cargo.lock b/lobby-worker/broker-wasm/Cargo.lock index ced20ecd8f..189a72fae2 100644 --- a/lobby-worker/broker-wasm/Cargo.lock +++ b/lobby-worker/broker-wasm/Cargo.lock @@ -186,7 +186,7 @@ checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "lobby-broker" -version = "0.49.0" +version = "0.50.0" dependencies = [ "phase-engine", "serde", @@ -239,7 +239,7 @@ dependencies = [ [[package]] name = "phase-engine" -version = "0.49.0" +version = "0.50.0" dependencies = [ "im", "indexmap", diff --git a/scripts/changelog/state.json b/scripts/changelog/state.json index bb04d28076..dcf6d0a306 100644 --- a/scripts/changelog/state.json +++ b/scripts/changelog/state.json @@ -1,4 +1,4 @@ { - "lastTip": "19031cf857", - "lastPostedId": 188 + "lastTip": "907d219c7a", + "lastPostedId": 189 } diff --git a/scripts/migrate-mana-target-roles.mjs b/scripts/migrate-mana-target-roles.mjs index 884d9a6bab..c212382c94 100644 --- a/scripts/migrate-mana-target-roles.mjs +++ b/scripts/migrate-mana-target-roles.mjs @@ -57,7 +57,7 @@ * 2. Apply this migration: * node scripts/migrate-mana-target-roles.mjs * 3. Verify: - * cargo test -p engine --test integration + * cargo test -p phase-engine --test integration * In particular `mana_role_fixture_migration` asserts Carpet of Flowers is * a CountSource and Belbe is a Recipient — it is the canary for a mapping * that silently flipped. @@ -459,4 +459,4 @@ if (lineCount !== 1) { process.exit(1); } console.log(`\nWrote ${fixturePath} (canonical gzip -9 -n; ${out.length} JSON bytes).`); -console.log("Verify with: cargo test -p engine --test integration"); +console.log("Verify with: cargo test -p phase-engine --test integration");